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

View File

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

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Faxes',
'version': '19.0.2.0.0',
'category': 'Productivity',
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
'description': """
Fusion Faxes
============
Send faxes directly from Odoo using the RingCentral REST API.
Features:
---------
* Send faxes from Sale Orders and Invoices
* Fax history tracked per contact
* Fax log with status tracking (draft/sending/sent/failed)
* Cover page text support
* Multiple document attachments per fax
* Chatter integration for fax events
* RingCentral JWT authentication (server-to-server)
Copyright 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'mail',
'sale',
'sale_management',
'account',
],
'external_dependencies': {
'python': ['ringcentral'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/ir_sequence_data.xml',
'data/ir_cron_data.xml',
'views/fusion_fax_views.xml',
'views/dashboard_views.xml',
'views/res_config_settings_views.xml',
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/account_move_views.xml',
'wizard/send_fax_wizard_views.xml',
],
'installable': True,
'auto_install': False,
'application': True,
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="config_ringcentral_enabled" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_enabled</field>
<field name="value">False</field>
</record>
<record id="config_ringcentral_server_url" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_server_url</field>
<field name="value">https://platform.ringcentral.com</field>
</record>
<record id="config_ringcentral_client_id" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_client_id</field>
<field name="value"></field>
</record>
<record id="config_ringcentral_client_secret" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_client_secret</field>
<field name="value"></field>
</record>
<record id="config_ringcentral_jwt_token" model="ir.config_parameter">
<field name="key">fusion_faxes.ringcentral_jwt_token</field>
<field name="value"></field>
</record>
<record id="config_last_inbound_poll" model="ir.config_parameter">
<field name="key">fusion_faxes.last_inbound_poll</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="ir_cron_fetch_incoming_faxes" model="ir.cron">
<field name="name">Fusion Faxes: Fetch Incoming Faxes</field>
<field name="model_id" ref="model_fusion_fax"/>
<field name="state">code</field>
<field name="code">model._cron_fetch_incoming_faxes()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_fusion_fax" model="ir.sequence">
<field name="name">Fusion Fax</field>
<field name="code">fusion.fax</field>
<field name="prefix">FAX/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fusion_fax
from . import fusion_fax_document
from . import dashboard
from . import res_config_settings
from . import res_partner
from . import sale_order
from . import account_move

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
x_ff_fax_ids = fields.One2many(
'fusion.fax',
'account_move_id',
string='Faxes',
)
x_ff_fax_count = fields.Integer(
string='Fax Count',
compute='_compute_fax_count',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for move in self:
move.x_ff_fax_count = len(move.x_ff_fax_ids)
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this invoice."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Send Fax',
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'account.move',
'active_id': self.id,
},
}
def action_view_faxes(self):
"""Open fax history for this invoice."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Faxes',
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': [('account_move_id', '=', self.id)],
'context': {'default_account_move_id': self.id},
}

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models, _
import logging
_logger = logging.getLogger(__name__)
STATUS_DOMAINS = {
'all': [],
'sent': [('state', '=', 'sent')],
'failed': [('state', '=', 'failed')],
'draft': [('state', '=', 'draft')],
'received': [('state', '=', 'received')],
}
class FusionFaxDashboard(models.TransientModel):
_name = 'fusion.fax.dashboard'
_description = 'Fusion Fax Dashboard'
_rec_name = 'name'
name = fields.Char(default='Fax Dashboard', readonly=True)
# KPI stat fields
total_count = fields.Integer(compute='_compute_stats')
sent_count = fields.Integer(compute='_compute_stats')
received_count = fields.Integer(compute='_compute_stats')
failed_count = fields.Integer(compute='_compute_stats')
draft_count = fields.Integer(compute='_compute_stats')
# Recent faxes as a proper relational field for embedded list
recent_fax_ids = fields.Many2many(
'fusion.fax',
compute='_compute_recent_faxes',
)
def _compute_stats(self):
Fax = self.env['fusion.fax'].sudo()
for rec in self:
rec.total_count = Fax.search_count([])
rec.sent_count = Fax.search_count(STATUS_DOMAINS['sent'])
rec.received_count = Fax.search_count(STATUS_DOMAINS['received'])
rec.failed_count = Fax.search_count(STATUS_DOMAINS['failed'])
rec.draft_count = Fax.search_count(STATUS_DOMAINS['draft'])
def _compute_recent_faxes(self):
Fax = self.env['fusion.fax'].sudo()
for rec in self:
rec.recent_fax_ids = Fax.search([], order='create_date desc', limit=20)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_open_all(self):
return self._open_fax_list('All Faxes', STATUS_DOMAINS['all'])
def action_open_sent(self):
return self._open_fax_list('Sent Faxes', STATUS_DOMAINS['sent'])
def action_open_received(self):
return self._open_fax_list('Received Faxes', STATUS_DOMAINS['received'])
def action_open_failed(self):
return self._open_fax_list('Failed Faxes', STATUS_DOMAINS['failed'])
def action_open_draft(self):
return self._open_fax_list('Draft Faxes', STATUS_DOMAINS['draft'])
def _open_fax_list(self, name, domain):
return {
'type': 'ir.actions.act_window',
'name': name,
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': domain,
}
def action_send_fax(self):
return {
'type': 'ir.actions.act_window',
'name': 'Send Fax',
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,581 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
from datetime import datetime, timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from markupsafe import Markup
_logger = logging.getLogger(__name__)
class FusionFax(models.Model):
_name = 'fusion.fax'
_description = 'Fax Record'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
direction = fields.Selection([
('outbound', 'Outbound'),
('inbound', 'Inbound'),
], string='Direction', default='outbound', required=True, tracking=True)
partner_id = fields.Many2one(
'res.partner',
string='Recipient',
tracking=True,
)
fax_number = fields.Char(
string='Fax Number',
required=True,
tracking=True,
)
state = fields.Selection([
('draft', 'Draft'),
('sending', 'Sending'),
('sent', 'Sent'),
('failed', 'Failed'),
('received', 'Received'),
], string='Status', default='draft', required=True, tracking=True)
# Inbound fax fields
sender_number = fields.Char(
string='Sender Number',
readonly=True,
)
received_date = fields.Datetime(
string='Received Date',
readonly=True,
)
rc_read_status = fields.Char(
string='Read Status',
readonly=True,
)
# Computed display fields for list views (work regardless of direction)
display_date = fields.Datetime(
string='Date',
compute='_compute_display_fields',
store=True,
)
display_number = fields.Char(
string='Fax Number',
compute='_compute_display_fields',
store=True,
)
cover_page_text = fields.Text(string='Cover Page Text')
document_ids = fields.One2many(
'fusion.fax.document',
'fax_id',
string='Documents',
)
document_count = fields.Integer(
compute='_compute_document_count',
)
# Keep for backwards compat with existing records
attachment_ids = fields.Many2many(
'ir.attachment',
'fusion_fax_attachment_rel',
'fax_id',
'attachment_id',
string='Attachments (Legacy)',
)
ringcentral_message_id = fields.Char(
string='RingCentral Message ID',
readonly=True,
copy=False,
)
sent_date = fields.Datetime(
string='Sent Date',
readonly=True,
copy=False,
)
sent_by_id = fields.Many2one(
'res.users',
string='Sent By',
readonly=True,
default=lambda self: self.env.user,
)
page_count = fields.Integer(
string='Pages',
readonly=True,
copy=False,
)
error_message = fields.Text(
string='Error Message',
readonly=True,
copy=False,
)
# Links to source documents
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='set null',
tracking=True,
)
account_move_id = fields.Many2one(
'account.move',
string='Invoice',
ondelete='set null',
tracking=True,
)
def write(self, vals):
"""Post chatter message when a sale order or invoice is linked."""
old_so_ids = {rec.id: rec.sale_order_id.id for rec in self}
result = super().write(vals)
if 'sale_order_id' in vals:
for rec in self:
new_so = rec.sale_order_id
old_so_id = old_so_ids.get(rec.id)
if new_so and new_so.id != old_so_id:
rec._post_link_chatter_message(new_so)
# Also set the partner from the SO if not already matched
if not rec.partner_id and new_so.partner_id:
rec.partner_id = new_so.partner_id
return result
def _post_link_chatter_message(self, sale_order):
"""Post a message on the sale order when a fax is linked to it."""
self.ensure_one()
direction_label = 'Received' if self.direction == 'inbound' else 'Sent'
date_str = ''
if self.direction == 'inbound' and self.received_date:
date_str = self.received_date.strftime('%b %d, %Y %H:%M')
elif self.sent_date:
date_str = self.sent_date.strftime('%b %d, %Y %H:%M')
number = self.sender_number or self.fax_number or ''
body = Markup(
'<p><strong>Fax Linked</strong></p>'
'<p>%s fax <a href="/odoo/faxes/%s"><b>%s</b></a> has been linked to this order.</p>'
'<ul>'
'<li>Fax Number: %s</li>'
'<li>Date: %s</li>'
'<li>Pages: %s</li>'
'</ul>'
) % (direction_label, self.id, self.name, number,
date_str or '-', self.page_count or '-')
# Attach the fax documents to the chatter message
attachment_ids = self.document_ids.mapped('attachment_id').ids
sale_order.message_post(
body=body,
message_type='notification',
attachment_ids=attachment_ids,
)
@api.depends('document_ids')
def _compute_document_count(self):
for rec in self:
rec.document_count = len(rec.document_ids)
@api.depends('direction', 'sent_date', 'received_date', 'fax_number', 'sender_number')
def _compute_display_fields(self):
for rec in self:
if rec.direction == 'inbound':
rec.display_date = rec.received_date
rec.display_number = rec.sender_number or rec.fax_number
else:
rec.display_date = rec.sent_date
rec.display_number = rec.fax_number
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.fax') or _('New')
return super().create(vals_list)
# ------------------------------------------------------------------
# RingCentral SDK helpers
# ------------------------------------------------------------------
def _get_rc_sdk(self):
"""Initialize and authenticate the RingCentral SDK. Returns (sdk, platform) tuple."""
ICP = self.env['ir.config_parameter'].sudo()
enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False')
if enabled not in ('True', 'true', '1'):
raise UserError(_('RingCentral faxing is not enabled. Go to Settings > Fusion Faxes to enable it.'))
client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '')
client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '')
server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com')
jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '')
if not all([client_id, client_secret, jwt_token]):
raise UserError(_(
'RingCentral credentials are not configured. '
'Go to Settings > Fusion Faxes and enter Client ID, Client Secret, and JWT Token.'
))
try:
from ringcentral import SDK
except ImportError:
raise UserError(_(
'The ringcentral Python package is not installed. '
'Run: pip install ringcentral'
))
sdk = SDK(client_id, client_secret, server_url)
platform = sdk.platform()
platform.login(jwt=jwt_token)
return sdk, platform
def _get_ordered_attachments(self):
"""Return attachments in the correct order: document_ids by sequence, or legacy attachment_ids."""
self.ensure_one()
if self.document_ids:
return self.document_ids.sorted('sequence').mapped('attachment_id')
return self.attachment_ids
def _send_fax(self):
"""Send this fax record via RingCentral API."""
self.ensure_one()
attachments = self._get_ordered_attachments()
if not attachments:
raise UserError(_('Please attach at least one document to send.'))
self.write({'state': 'sending', 'error_message': False})
try:
sdk, platform = self._get_rc_sdk()
# Use the SDK's multipart builder
builder = sdk.create_multipart_builder()
# Set the JSON body (metadata)
body = {
'to': [{'phoneNumber': self.fax_number}],
'faxResolution': 'High',
}
if self.cover_page_text:
body['coverPageText'] = self.cover_page_text
builder.set_body(body)
# Add document attachments in sequence order
for attachment in attachments:
file_content = base64.b64decode(attachment.datas)
builder.add((attachment.name, file_content))
# Build the request and send
request = builder.request('/restapi/v1.0/account/~/extension/~/fax')
response = platform.send_request(request)
result = response.json()
# Extract response fields
message_id = ''
page_count = 0
if hasattr(result, 'id'):
message_id = str(result.id)
elif isinstance(result, dict):
message_id = str(result.get('id', ''))
if hasattr(result, 'pageCount'):
page_count = result.pageCount
elif isinstance(result, dict):
page_count = result.get('pageCount', 0)
self.write({
'state': 'sent',
'ringcentral_message_id': message_id,
'sent_date': fields.Datetime.now(),
'sent_by_id': self.env.user.id,
'page_count': page_count,
})
# Post chatter message on linked documents
self._post_fax_chatter_message(success=True)
_logger.info("Fax %s sent successfully. RC Message ID: %s", self.name, message_id)
except UserError:
raise
except Exception as e:
error_msg = str(e)
self.write({
'state': 'failed',
'error_message': error_msg,
})
self._post_fax_chatter_message(success=False)
_logger.exception("Fax %s failed to send", self.name)
raise UserError(_('Fax sending failed: %s') % error_msg)
def _post_fax_chatter_message(self, success=True):
"""Post a chatter message on the linked sale order or invoice."""
self.ensure_one()
if success:
body = Markup(
'<p><strong>Fax Sent</strong></p>'
'<p>Fax <b>%s</b> sent successfully to <b>%s</b> (%s).</p>'
'<p>Pages: %s | RingCentral ID: %s</p>'
) % (self.name, self.partner_id.name, self.fax_number,
self.page_count or '-', self.ringcentral_message_id or '-')
else:
body = Markup(
'<p><strong>Fax Failed</strong></p>'
'<p>Fax <b>%s</b> to <b>%s</b> (%s) failed.</p>'
'<p>Error: %s</p>'
) % (self.name, self.partner_id.name, self.fax_number,
self.error_message or 'Unknown error')
if self.sale_order_id:
self.sale_order_id.message_post(body=body, message_type='notification')
if self.account_move_id:
self.account_move_id.message_post(body=body, message_type='notification')
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_send(self):
"""Button action to send this fax."""
self.ensure_one()
self._send_fax()
def action_retry(self):
"""Retry a failed fax."""
self.ensure_one()
if self.state != 'failed':
raise UserError(_('Only failed faxes can be retried.'))
self._send_fax()
def action_resend(self):
"""Resend a previously sent fax with all the same attachments."""
self.ensure_one()
if self.state != 'sent':
raise UserError(_('Only sent faxes can be resent.'))
self._send_fax()
def action_open_sale_order(self):
"""Open the linked sale order."""
self.ensure_one()
if not self.sale_order_id:
return
return {
'type': 'ir.actions.act_window',
'name': self.sale_order_id.name,
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
}
def action_reset_to_draft(self):
"""Reset a failed fax back to draft."""
self.ensure_one()
if self.state not in ('failed',):
raise UserError(_('Only failed faxes can be reset to draft.'))
self.write({
'state': 'draft',
'error_message': False,
})
# ------------------------------------------------------------------
# Incoming fax polling
# ------------------------------------------------------------------
@api.model
def _cron_fetch_incoming_faxes(self):
"""Poll RingCentral for inbound faxes and create records."""
ICP = self.env['ir.config_parameter'].sudo()
enabled = ICP.get_param('fusion_faxes.ringcentral_enabled', 'False')
if enabled not in ('True', 'true', '1'):
return
client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '')
client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '')
server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com')
jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '')
if not all([client_id, client_secret, jwt_token]):
_logger.warning("Fusion Faxes: RingCentral credentials not configured, skipping inbound poll.")
return
try:
from ringcentral import SDK
except ImportError:
_logger.error("Fusion Faxes: ringcentral package not installed.")
return
# Determine dateFrom: last poll or 1 year ago for first run
last_poll = ICP.get_param('fusion_faxes.last_inbound_poll', '')
if last_poll:
date_from = last_poll
else:
one_year_ago = datetime.utcnow() - timedelta(days=365)
date_from = one_year_ago.strftime('%Y-%m-%dT%H:%M:%S.000Z')
try:
sdk = SDK(client_id, client_secret, server_url)
platform = sdk.platform()
platform.login(jwt=jwt_token)
total_imported = 0
total_skipped = 0
# Fetch first page
endpoint = (
'/restapi/v1.0/account/~/extension/~/message-store'
f'?messageType=Fax&direction=Inbound&dateFrom={date_from}'
'&perPage=100'
)
while endpoint:
response = platform.get(endpoint)
data = response.json()
records = []
if hasattr(data, 'records'):
records = data.records
elif isinstance(data, dict):
records = data.get('records', [])
for msg in records:
msg_id = str(msg.get('id', '')) if isinstance(msg, dict) else str(getattr(msg, 'id', ''))
# Deduplicate
existing = self.search_count([('ringcentral_message_id', '=', msg_id)])
if existing:
total_skipped += 1
continue
imported = self._import_inbound_fax(msg, platform)
if imported:
total_imported += 1
# Handle pagination
endpoint = None
navigation = None
if isinstance(data, dict):
navigation = data.get('navigation', {})
elif hasattr(data, 'navigation'):
navigation = data.navigation
if navigation:
next_page = None
if isinstance(navigation, dict):
next_page = navigation.get('nextPage', {})
elif hasattr(navigation, 'nextPage'):
next_page = navigation.nextPage
if next_page:
next_uri = next_page.get('uri', '') if isinstance(next_page, dict) else getattr(next_page, 'uri', '')
if next_uri:
endpoint = next_uri
# Update last poll timestamp
ICP.set_param('fusion_faxes.last_inbound_poll', datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.000Z'))
if total_imported:
_logger.info("Fusion Faxes: Imported %d inbound faxes, skipped %d duplicates.", total_imported, total_skipped)
except Exception:
_logger.exception("Fusion Faxes: Error fetching inbound faxes from RingCentral.")
def _import_inbound_fax(self, msg, platform):
"""Import a single inbound fax message from RingCentral."""
try:
# Extract fields (handle both dict and SDK JsonObject responses)
if isinstance(msg, dict):
msg_id = str(msg.get('id', ''))
from_info = msg.get('from', {})
sender = from_info.get('phoneNumber', '') if isinstance(from_info, dict) else ''
creation_time = msg.get('creationTime', '')
read_status = msg.get('readStatus', '')
page_count = msg.get('faxPageCount', 0)
attachments = msg.get('attachments', [])
else:
msg_id = str(getattr(msg, 'id', ''))
# SDK exposes 'from' as 'from_' since 'from' is a Python keyword
from_info = getattr(msg, 'from_', None) or getattr(msg, 'from', None)
sender = getattr(from_info, 'phoneNumber', '') if from_info else ''
creation_time = getattr(msg, 'creationTime', '')
read_status = getattr(msg, 'readStatus', '')
page_count = getattr(msg, 'faxPageCount', 0)
attachments = getattr(msg, 'attachments', [])
# Parse received datetime
received_dt = False
if creation_time:
try:
clean_time = creation_time.replace('Z', '+00:00')
received_dt = datetime.fromisoformat(clean_time).strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, AttributeError):
received_dt = False
# Try to match sender to a partner
partner = False
if sender:
partner = self.env['res.partner'].sudo().search(
[('x_ff_fax_number', '=', sender)], limit=1
)
# Download the PDF attachment
document_lines = []
for att in attachments:
att_uri = att.get('uri', '') if isinstance(att, dict) else getattr(att, 'uri', '')
att_type = att.get('contentType', '') if isinstance(att, dict) else getattr(att, 'contentType', '')
if not att_uri:
continue
try:
att_response = platform.get(att_uri)
pdf_content = att_response.body()
if not pdf_content:
continue
file_ext = 'pdf' if 'pdf' in (att_type or '') else 'bin'
file_name = f'FAX_IN_{msg_id}.{file_ext}'
ir_attachment = self.env['ir.attachment'].sudo().create({
'name': file_name,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'mimetype': att_type or 'application/pdf',
'res_model': 'fusion.fax',
})
document_lines.append((0, 0, {
'sequence': 10,
'attachment_id': ir_attachment.id,
}))
except Exception:
_logger.exception("Fusion Faxes: Failed to download attachment for message %s", msg_id)
# Create the fax record
self.sudo().create({
'direction': 'inbound',
'state': 'received',
'fax_number': sender or 'Unknown',
'sender_number': sender,
'partner_id': partner.id if partner else False,
'ringcentral_message_id': msg_id,
'received_date': received_dt,
'page_count': page_count or 0,
'rc_read_status': read_status,
'document_ids': document_lines,
})
return True
except Exception:
_logger.exception("Fusion Faxes: Failed to import inbound fax message.")
return False

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FusionFaxDocument(models.Model):
_name = 'fusion.fax.document'
_description = 'Fax Document Line'
_order = 'sequence, id'
fax_id = fields.Many2one(
'fusion.fax',
string='Fax',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(
string='Order',
default=10,
)
attachment_id = fields.Many2one(
'ir.attachment',
string='Document',
required=True,
ondelete='cascade',
)
file_name = fields.Char(
related='attachment_id.name',
string='File Name',
)
mimetype = fields.Char(
related='attachment_id.mimetype',
string='Type',
)
def action_preview(self):
"""Open the attachment in Odoo's built-in PDF viewer dialog."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_claims.preview_document',
'params': {
'attachment_id': self.attachment_id.id,
'title': self.file_name or 'Document Preview',
},
}

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
import logging
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
ff_ringcentral_enabled = fields.Boolean(
string='Enable RingCentral Faxing',
config_parameter='fusion_faxes.ringcentral_enabled',
)
ff_ringcentral_server_url = fields.Char(
string='RingCentral Server URL',
config_parameter='fusion_faxes.ringcentral_server_url',
default='https://platform.ringcentral.com',
)
ff_ringcentral_client_id = fields.Char(
string='Client ID',
config_parameter='fusion_faxes.ringcentral_client_id',
groups='fusion_faxes.group_fax_manager',
)
ff_ringcentral_client_secret = fields.Char(
string='Client Secret',
config_parameter='fusion_faxes.ringcentral_client_secret',
groups='fusion_faxes.group_fax_manager',
)
ff_ringcentral_jwt_token = fields.Char(
string='JWT Token',
config_parameter='fusion_faxes.ringcentral_jwt_token',
groups='fusion_faxes.group_fax_manager',
)
def action_test_ringcentral_connection(self):
"""Test connection to RingCentral using stored credentials."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
client_id = ICP.get_param('fusion_faxes.ringcentral_client_id', '')
client_secret = ICP.get_param('fusion_faxes.ringcentral_client_secret', '')
server_url = ICP.get_param('fusion_faxes.ringcentral_server_url', 'https://platform.ringcentral.com')
jwt_token = ICP.get_param('fusion_faxes.ringcentral_jwt_token', '')
if not all([client_id, client_secret, jwt_token]):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Configuration Missing',
'message': 'Please fill in Client ID, Client Secret, and JWT Token before testing.',
'type': 'warning',
'sticky': False,
},
}
try:
from ringcentral import SDK
sdk = SDK(client_id, client_secret, server_url)
platform = sdk.platform()
platform.login(jwt=jwt_token)
# Fetch account info to verify
res = platform.get('/restapi/v1.0/account/~/extension/~')
ext_info = res.json()
ext_name = ext_info.name if hasattr(ext_info, 'name') else str(ext_info.get('name', 'Unknown'))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Connection Successful',
'message': f'Connected to RingCentral as: {ext_name}',
'type': 'success',
'sticky': False,
},
}
except ImportError:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'SDK Not Installed',
'message': 'The ringcentral Python package is not installed. Run: pip install ringcentral',
'type': 'danger',
'sticky': True,
},
}
except Exception as e:
_logger.exception("RingCentral connection test failed")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Connection Failed',
'message': str(e),
'type': 'danger',
'sticky': True,
},
}
def set_values(self):
"""Protect credential fields from being blanked accidentally."""
protected_keys = [
'fusion_faxes.ringcentral_client_id',
'fusion_faxes.ringcentral_client_secret',
'fusion_faxes.ringcentral_jwt_token',
]
ICP = self.env['ir.config_parameter'].sudo()
for key in protected_keys:
field_map = {
'fusion_faxes.ringcentral_client_id': 'ff_ringcentral_client_id',
'fusion_faxes.ringcentral_client_secret': 'ff_ringcentral_client_secret',
'fusion_faxes.ringcentral_jwt_token': 'ff_ringcentral_jwt_token',
}
field_name = field_map[key]
new_val = getattr(self, field_name, None)
if new_val in (None, False, ''):
existing = ICP.get_param(key, '')
if existing:
ICP.set_param(key, existing)
return super().set_values()

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
x_ff_fax_number = fields.Char(string='Fax Number')
x_ff_fax_ids = fields.One2many(
'fusion.fax',
'partner_id',
string='Fax History',
)
x_ff_fax_count = fields.Integer(
string='Fax Count',
compute='_compute_fax_count',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for partner in self:
partner.x_ff_fax_count = len(partner.x_ff_fax_ids)
def action_view_faxes(self):
"""Open fax history for this contact."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Fax History',
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.id)],
'context': {'default_partner_id': self.id},
}

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
x_ff_fax_ids = fields.One2many(
'fusion.fax',
'sale_order_id',
string='Faxes',
)
x_ff_fax_count = fields.Integer(
string='Fax Count',
compute='_compute_fax_count',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for order in self:
order.x_ff_fax_count = len(order.x_ff_fax_ids)
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this sale order."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Send Fax',
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'sale.order',
'active_id': self.id,
},
}
def action_view_faxes(self):
"""Open fax history for this sale order."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Faxes',
'res_model': 'fusion.fax',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}

View File

@@ -0,0 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_fax_user,fusion.fax.user,model_fusion_fax,group_fax_user,1,1,1,0
access_fusion_fax_manager,fusion.fax.manager,model_fusion_fax,group_fax_manager,1,1,1,1
access_fusion_send_fax_wizard_user,fusion.send.fax.wizard.user,model_fusion_faxes_send_fax_wizard,group_fax_user,1,1,1,1
access_fusion_send_fax_wizard_line_user,fusion.send.fax.wizard.line.user,model_fusion_faxes_send_fax_wizard_line,group_fax_user,1,1,1,1
access_fusion_fax_document_user,fusion.fax.document.user,model_fusion_fax_document,group_fax_user,1,1,1,0
access_fusion_fax_document_manager,fusion.fax.document.manager,model_fusion_fax_document,group_fax_manager,1,1,1,1
access_fusion_fax_dashboard_user,fusion.fax.dashboard.user,model_fusion_fax_dashboard,group_fax_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_fax_user fusion.fax.user model_fusion_fax group_fax_user 1 1 1 0
3 access_fusion_fax_manager fusion.fax.manager model_fusion_fax group_fax_manager 1 1 1 1
4 access_fusion_send_fax_wizard_user fusion.send.fax.wizard.user model_fusion_faxes_send_fax_wizard group_fax_user 1 1 1 1
5 access_fusion_send_fax_wizard_line_user fusion.send.fax.wizard.line.user model_fusion_faxes_send_fax_wizard_line group_fax_user 1 1 1 1
6 access_fusion_fax_document_user fusion.fax.document.user model_fusion_fax_document group_fax_user 1 1 1 0
7 access_fusion_fax_document_manager fusion.fax.document.manager model_fusion_fax_document group_fax_manager 1 1 1 1
8 access_fusion_fax_dashboard_user fusion.fax.dashboard.user model_fusion_fax_dashboard group_fax_user 1 1 1 1

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- User group: can send faxes and view own fax history -->
<record id="group_fax_user" model="res.groups">
<field name="name">Fusion Faxes / User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Manager group: can view all faxes and configure settings -->
<record id="group_fax_manager" model="res.groups">
<field name="name">Fusion Faxes / Manager</field>
<field name="implied_ids" eval="[(4, ref('group_fax_user'))]"/>
</record>
<!-- Record rules -->
<!-- Users see only their own faxes -->
<record id="rule_fax_user_own" model="ir.rule">
<field name="name">Fax: user sees own faxes</field>
<field name="model_id" ref="model_fusion_fax"/>
<field name="domain_force">[('sent_by_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fax_user'))]"/>
</record>
<!-- Managers see all faxes -->
<record id="rule_fax_manager_all" model="ir.rule">
<field name="name">Fax: manager sees all faxes</field>
<field name="model_id" ref="model_fusion_fax"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fax_manager'))]"/>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Send Fax button + smart button to invoice form -->
<record id="view_account_move_form_inherit_fusion_faxes" model="ir.ui.view">
<field name="name">account.move.form.inherit.fusion_faxes</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<!-- Smart button for fax count -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_faxes" type="object"
class="oe_stat_button" icon="fa-fax"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
</button>
</xpath>
<!-- Send Fax header button -->
<xpath expr="//header" position="inside">
<button name="action_send_fax" string="Send Fax"
type="object" class="btn-secondary"
icon="fa-fax"
groups="fusion_faxes.group_fax_user"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Dashboard Form View -->
<record id="view_fusion_fax_dashboard_form" model="ir.ui.view">
<field name="name">fusion.fax.dashboard.form</field>
<field name="model">fusion.fax.dashboard</field>
<field name="arch" type="xml">
<form string="Fax Dashboard" create="0" delete="0" edit="0">
<sheet>
<field name="name" invisible="1"/>
<!-- Title -->
<div class="mb-4">
<span class="text-muted">Overview of fax activity and quick actions</span>
</div>
<!-- KPI Stat Cards -->
<div class="d-flex flex-nowrap gap-3 mb-4">
<!-- Total Faxes -->
<div class="flex-fill">
<button name="action_open_all" type="object"
class="btn btn-primary w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="total_count"/>
</div>
<div style="font-size: 0.85rem;">Total Faxes</div>
</div>
</button>
</div>
<!-- Sent -->
<div class="flex-fill">
<button name="action_open_sent" type="object"
class="btn btn-success w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="sent_count"/>
</div>
<div style="font-size: 0.85rem;">Sent</div>
</div>
</button>
</div>
<!-- Received -->
<div class="flex-fill">
<button name="action_open_received" type="object"
class="btn btn-info w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="received_count"/>
</div>
<div style="font-size: 0.85rem;">Received</div>
</div>
</button>
</div>
<!-- Failed -->
<div class="flex-fill" invisible="failed_count == 0">
<button name="action_open_failed" type="object"
class="btn btn-danger w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="failed_count"/>
</div>
<div style="font-size: 0.85rem;">Failed</div>
</div>
</button>
</div>
<!-- Draft -->
<div class="flex-fill" invisible="draft_count == 0">
<button name="action_open_draft" type="object"
class="btn btn-warning w-100 p-0 border-0 rounded-3">
<div class="text-center py-3 px-2">
<div class="fw-bold" style="font-size: 2rem;">
<field name="draft_count"/>
</div>
<div style="font-size: 0.85rem;">Draft</div>
</div>
</button>
</div>
</div>
<!-- Quick Action Buttons -->
<div class="d-flex gap-2 mb-4">
<button name="action_send_fax" type="object"
class="btn btn-primary"
icon="fa-fax">
Send New Fax
</button>
<button name="action_open_all" type="object"
class="btn btn-secondary"
icon="fa-list">
View All Faxes
</button>
<button name="action_open_received" type="object"
class="btn btn-secondary"
icon="fa-download">
View Received
</button>
<button name="action_open_failed" type="object"
class="btn btn-secondary"
icon="fa-exclamation-triangle"
invisible="failed_count == 0">
View Failed
</button>
</div>
<!-- Recent Fax History -->
<separator string="Recent Fax History"/>
<field name="recent_fax_ids" nolabel="1" readonly="1">
<list decoration-danger="state == 'failed'"
decoration-success="state in ('sent', 'received')"
decoration-info="state == 'sending'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"/>
<field name="display_date" string="Date"/>
<field name="partner_id"/>
<field name="display_number" string="Fax Number"/>
<field name="page_count"/>
<field name="state" widget="badge"
decoration-success="state in ('sent', 'received')"
decoration-danger="state == 'failed'"
decoration-info="state == 'sending'"
decoration-warning="state == 'draft'"/>
<field name="sent_by_id" optional="show"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- Dashboard Action -->
<record id="action_fusion_fax_dashboard" model="ir.actions.act_window">
<field name="name">Dashboard</field>
<field name="res_model">fusion.fax.dashboard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fusion_fax_dashboard_form"/>
<field name="target">current</field>
</record>
<!-- Dashboard Menu (first item under Faxes root) -->
<menuitem id="menu_fusion_fax_dashboard"
name="Dashboard"
parent="menu_fusion_faxes_root"
action="action_fusion_fax_dashboard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,259 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Fax Form View -->
<record id="view_fusion_fax_form" model="ir.ui.view">
<field name="name">fusion.fax.form</field>
<field name="model">fusion.fax</field>
<field name="arch" type="xml">
<form string="Fax">
<header>
<!-- Outbound buttons only -->
<button name="action_send" string="Send Fax"
type="object" class="btn-primary"
icon="fa-fax"
invisible="state != 'draft' or direction == 'inbound'"/>
<button name="action_resend" string="Resend Fax"
type="object" class="btn-primary"
icon="fa-repeat"
invisible="state != 'sent' or direction == 'inbound'"
confirm="This will resend the fax with all attachments. Continue?"/>
<button name="action_retry" string="Retry"
type="object" class="btn-primary"
icon="fa-refresh"
invisible="state != 'failed' or direction == 'inbound'"/>
<button name="action_reset_to_draft" string="Reset to Draft"
type="object"
invisible="state != 'failed' or direction == 'inbound'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,sending,sent,received"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<field name="sale_order_id" widget="statinfo" string="Sale Order"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Direction badge -->
<div class="mb-3">
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"
readonly="1"/>
</div>
<!-- Outbound fields -->
<group invisible="direction == 'inbound'">
<group>
<field name="partner_id"/>
<field name="fax_number"/>
<field name="sent_by_id"/>
</group>
<group>
<field name="sent_date"/>
<field name="page_count"/>
<field name="ringcentral_message_id"/>
</group>
</group>
<!-- Inbound fields -->
<group invisible="direction == 'outbound'">
<group>
<field name="sender_number"/>
<field name="partner_id" string="Matched Contact"/>
<field name="sale_order_id" string="Link to Sale Order"
options="{'no_create': True}"/>
</group>
<group>
<field name="received_date"/>
<field name="page_count"/>
<field name="rc_read_status"/>
<field name="ringcentral_message_id"/>
</group>
</group>
<group invisible="direction == 'inbound'">
<field name="cover_page_text" placeholder="Optional cover page text..."/>
</group>
<!-- Ordered Documents with preview -->
<separator string="Documents"/>
<field name="document_ids" nolabel="1">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="attachment_id"/>
<field name="file_name" readonly="1"/>
<field name="mimetype" readonly="1"/>
<button name="action_preview" type="object"
string="Preview" icon="fa-eye"
class="btn-link"/>
</list>
</field>
<group string="Linked Records"
invisible="direction == 'inbound'">
<group>
<field name="sale_order_id"/>
</group>
<group>
<field name="account_move_id"/>
</group>
</group>
<group string="Error Details" invisible="state != 'failed'">
<field name="error_message" readonly="1" nolabel="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Fax List View -->
<record id="view_fusion_fax_list" model="ir.ui.view">
<field name="name">fusion.fax.list</field>
<field name="model">fusion.fax</field>
<field name="arch" type="xml">
<list string="Faxes" decoration-danger="state == 'failed'"
decoration-success="state in ('sent', 'received')"
decoration-info="state == 'sending'">
<field name="name"/>
<field name="direction" widget="badge"
decoration-info="direction == 'outbound'"
decoration-success="direction == 'inbound'"/>
<field name="display_date" string="Date"/>
<field name="partner_id"/>
<field name="display_number" string="Fax Number"/>
<field name="page_count"/>
<field name="state" widget="badge"
decoration-success="state in ('sent', 'received')"
decoration-danger="state == 'failed'"
decoration-info="state == 'sending'"
decoration-warning="state == 'draft'"/>
<field name="sent_by_id" optional="show"/>
<field name="sale_order_id" optional="hide"/>
<field name="account_move_id" optional="hide"/>
</list>
</field>
</record>
<!-- Fax Search View -->
<record id="view_fusion_fax_search" model="ir.ui.view">
<field name="name">fusion.fax.search</field>
<field name="model">fusion.fax</field>
<field name="arch" type="xml">
<search string="Search Faxes">
<field name="name"/>
<field name="partner_id"/>
<field name="fax_number"/>
<field name="sender_number"/>
<field name="sale_order_id"/>
<field name="account_move_id"/>
<separator/>
<filter string="Outbound" name="filter_outbound"
domain="[('direction', '=', 'outbound')]"/>
<filter string="Inbound" name="filter_inbound"
domain="[('direction', '=', 'inbound')]"/>
<separator/>
<filter string="Draft" name="filter_draft"
domain="[('state', '=', 'draft')]"/>
<filter string="Sent" name="filter_sent"
domain="[('state', '=', 'sent')]"/>
<filter string="Received" name="filter_received"
domain="[('state', '=', 'received')]"/>
<filter string="Failed" name="filter_failed"
domain="[('state', '=', 'failed')]"/>
<separator/>
<filter string="My Faxes" name="filter_my_faxes"
domain="[('sent_by_id', '=', uid)]"/>
<separator/>
<filter string="Direction" name="group_direction"
context="{'group_by': 'direction'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Recipient" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Sent Date" name="group_date"
context="{'group_by': 'sent_date:month'}"/>
<filter string="Sent By" name="group_user"
context="{'group_by': 'sent_by_id'}"/>
</search>
</field>
</record>
<!-- Menu and Actions -->
<record id="action_fusion_fax" model="ir.actions.act_window">
<field name="name">All Faxes</field>
<field name="res_model">fusion.fax</field>
<field name="path">faxes</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_fax_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No faxes yet
</p>
<p>
Send faxes from Sale Orders or Invoices using the "Send Fax" button,
or create one manually from here.
</p>
</field>
</record>
<!-- Received Faxes Action -->
<record id="action_fusion_fax_received" model="ir.actions.act_window">
<field name="name">Received Faxes</field>
<field name="res_model">fusion.fax</field>
<field name="view_mode">list,form</field>
<field name="domain">[('direction', '=', 'inbound')]</field>
<field name="search_view_id" ref="view_fusion_fax_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No received faxes yet
</p>
<p>
Incoming faxes are automatically synced from RingCentral every 5 minutes.
</p>
</field>
</record>
<!-- Sent Faxes Action -->
<record id="action_fusion_fax_sent" model="ir.actions.act_window">
<field name="name">Sent Faxes</field>
<field name="res_model">fusion.fax</field>
<field name="view_mode">list,form</field>
<field name="domain">[('direction', '=', 'outbound')]</field>
<field name="search_view_id" ref="view_fusion_fax_search"/>
</record>
<!-- Top-level Faxes menu -->
<menuitem id="menu_fusion_faxes_root"
name="Faxes"
web_icon="fusion_faxes,static/description/icon.png"
sequence="45"/>
<menuitem id="menu_fusion_fax_list"
name="All Faxes"
parent="menu_fusion_faxes_root"
action="action_fusion_fax"
sequence="10"/>
<menuitem id="menu_fusion_fax_received"
name="Received Faxes"
parent="menu_fusion_faxes_root"
action="action_fusion_fax_received"
sequence="15"/>
<menuitem id="menu_fusion_fax_sent"
name="Sent Faxes"
parent="menu_fusion_faxes_root"
action="action_fusion_fax_sent"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_fusion_faxes" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion_faxes</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 Faxes" string="Fusion Faxes" name="fusion_faxes"
groups="fusion_faxes.group_fax_manager">
<h2>RingCentral Configuration</h2>
<div class="row mt-4 o_settings_container">
<!-- Enable toggle -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ff_ringcentral_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ff_ringcentral_enabled"/>
<div class="text-muted">
Enable sending faxes via RingCentral API.
</div>
</div>
</div>
<!-- Server URL -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Server URL</span>
<div class="text-muted">
RingCentral API server URL. Use https://platform.devtest.ringcentral.com for sandbox.
</div>
<div class="mt-2">
<field name="ff_ringcentral_server_url"
placeholder="https://platform.ringcentral.com"/>
</div>
</div>
</div>
<!-- Client ID -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Client ID</span>
<div class="text-muted">
From your RingCentral Developer Console app.
</div>
<div class="mt-2">
<field name="ff_ringcentral_client_id"
placeholder="Enter Client ID..."/>
</div>
</div>
</div>
<!-- Client Secret -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Client Secret</span>
<div class="text-muted">
From your RingCentral Developer Console app.
</div>
<div class="mt-2">
<field name="ff_ringcentral_client_secret"
password="True"
placeholder="Enter Client Secret..."/>
</div>
</div>
</div>
<!-- JWT Token -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">JWT Token</span>
<div class="text-muted">
Generated from RingCentral Developer Console > Credentials > JWT.
</div>
<div class="mt-2">
<field name="ff_ringcentral_jwt_token"
password="True"
placeholder="Enter JWT Token..."/>
</div>
</div>
</div>
<!-- Test Connection button -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">
<div class="o_setting_right_pane">
<span class="o_form_label">Test Connection</span>
<div class="text-muted">
Verify your RingCentral credentials are working.
</div>
<div class="mt-2">
<button name="action_test_ringcentral_connection"
string="Test Connection"
type="object"
class="btn-primary"
icon="fa-plug"/>
</div>
</div>
</div>
</div>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add fax number field + fax history tab to contact form -->
<record id="view_partner_form_inherit_fusion_faxes" model="ir.ui.view">
<field name="name">res.partner.form.inherit.fusion_faxes</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<!-- Add fax number after phone field -->
<xpath expr="//field[@name='phone']" position="after">
<field name="x_ff_fax_number" placeholder="Fax number..."/>
</xpath>
<!-- Add smart button for fax count -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_faxes" type="object"
class="oe_stat_button" icon="fa-fax"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
</button>
</xpath>
<!-- Add Fax History tab -->
<xpath expr="//page[@name='internal_notes']" position="after">
<page string="Fax History" name="fax_history"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_ids" readonly="1">
<list>
<field name="name"/>
<field name="sent_date"/>
<field name="fax_number"/>
<field name="page_count"/>
<field name="state" widget="badge"
decoration-success="state == 'sent'"
decoration-danger="state == 'failed'"/>
<field name="sent_by_id"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Send Fax button + smart button to sale order form -->
<record id="view_sale_order_form_inherit_fusion_faxes" model="ir.ui.view">
<field name="name">sale.order.form.inherit.fusion_faxes</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Smart button for fax count -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_faxes" type="object"
class="oe_stat_button" icon="fa-fax"
invisible="x_ff_fax_count == 0">
<field name="x_ff_fax_count" widget="statinfo" string="Faxes"/>
</button>
</xpath>
<!-- Send Fax header button -->
<xpath expr="//header" position="inside">
<button name="action_send_fax" string="Send Fax"
type="object" class="btn-secondary"
icon="fa-fax"
groups="fusion_faxes.group_fax_user"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class SendFaxWizard(models.TransientModel):
_name = 'fusion_faxes.send.fax.wizard'
_description = 'Send Fax Wizard'
partner_id = fields.Many2one(
'res.partner',
string='Recipient',
required=True,
)
fax_number = fields.Char(
string='Fax Number',
required=True,
)
cover_page_text = fields.Text(
string='Cover Page Text',
)
document_line_ids = fields.One2many(
'fusion_faxes.send.fax.wizard.line',
'wizard_id',
string='Documents',
)
generate_pdf = fields.Boolean(
string='Attach PDF of Source Document',
default=True,
help='Automatically generate and attach a PDF of the sale order or invoice.',
)
# Context links
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
account_move_id = fields.Many2one('account.move', string='Invoice')
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id and self.partner_id.x_ff_fax_number:
self.fax_number = self.partner_id.x_ff_fax_number
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
context = self.env.context
# Auto-fill from sale order context
if context.get('active_model') == 'sale.order' and context.get('active_id'):
order = self.env['sale.order'].browse(context['active_id'])
res['sale_order_id'] = order.id
res['partner_id'] = order.partner_id.id
if order.partner_id.x_ff_fax_number:
res['fax_number'] = order.partner_id.x_ff_fax_number
# Auto-fill from invoice context
elif context.get('active_model') == 'account.move' and context.get('active_id'):
move = self.env['account.move'].browse(context['active_id'])
res['account_move_id'] = move.id
res['partner_id'] = move.partner_id.id
if move.partner_id.x_ff_fax_number:
res['fax_number'] = move.partner_id.x_ff_fax_number
# Pre-attach documents when forwarding a fax
forward_ids = context.get('forward_attachment_ids')
if forward_ids:
lines = []
for seq, att_id in enumerate(forward_ids, start=1):
lines.append((0, 0, {
'sequence': seq * 10,
'attachment_id': att_id,
'file_name': self.env['ir.attachment'].browse(att_id).name,
}))
res['document_line_ids'] = lines
res['generate_pdf'] = False
return res
def _generate_source_pdf(self):
"""Generate a PDF of the linked sale order or invoice."""
self.ensure_one()
if self.sale_order_id:
report = self.env.ref('sale.action_report_saleorder')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.sale_order_id.id])
filename = f'{self.sale_order_id.name}.pdf'
return filename, pdf_content
elif self.account_move_id:
report = self.env.ref('account.account_invoices')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.account_move_id.id])
filename = f'{self.account_move_id.name}.pdf'
return filename, pdf_content
return None, None
def action_send(self):
"""Create a fusion.fax record and send it."""
self.ensure_one()
if not self.fax_number:
raise UserError(_('Please enter a fax number.'))
# Collect attachment IDs from ordered wizard lines
ordered_attachment_ids = list(
self.document_line_ids.sorted('sequence').mapped('attachment_id').ids
)
# Generate PDF of source document if requested
if self.generate_pdf:
filename, pdf_content = self._generate_source_pdf()
if pdf_content:
attachment = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'mimetype': 'application/pdf',
'res_model': 'fusion.fax',
})
# Generated PDF goes first (sequence 1)
ordered_attachment_ids.insert(0, attachment.id)
if not ordered_attachment_ids:
raise UserError(_('Please attach at least one document or enable PDF generation.'))
# Build document lines preserving the wizard ordering
document_lines = []
for seq, att_id in enumerate(ordered_attachment_ids, start=1):
document_lines.append((0, 0, {
'sequence': seq * 10,
'attachment_id': att_id,
}))
# Create the fax record
fax = self.env['fusion.fax'].create({
'partner_id': self.partner_id.id,
'fax_number': self.fax_number,
'cover_page_text': self.cover_page_text,
'document_ids': document_lines,
'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
'account_move_id': self.account_move_id.id if self.account_move_id else False,
'sent_by_id': self.env.user.id,
})
# Send immediately
fax._send_fax()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Fax Sent'),
'message': _('Fax %s sent successfully to %s.') % (fax.name, self.fax_number),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class SendFaxWizardLine(models.TransientModel):
_name = 'fusion_faxes.send.fax.wizard.line'
_description = 'Send Fax Wizard Document Line'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'fusion_faxes.send.fax.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(
string='Order',
default=10,
)
file_upload = fields.Binary(
string='Upload File',
)
file_name = fields.Char(
string='File Name',
)
attachment_id = fields.Many2one(
'ir.attachment',
string='Attachment',
readonly=True,
)
@api.onchange('file_upload')
def _onchange_file_upload(self):
"""Create an ir.attachment when a file is uploaded."""
if self.file_upload and self.file_name:
attachment = self.env['ir.attachment'].create({
'name': self.file_name,
'type': 'binary',
'datas': self.file_upload,
'res_model': 'fusion.fax',
})
self.attachment_id = attachment.id

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Send Fax Wizard Form -->
<record id="view_send_fax_wizard_form" model="ir.ui.view">
<field name="name">fusion_faxes.send.fax.wizard.form</field>
<field name="model">fusion_faxes.send.fax.wizard</field>
<field name="arch" type="xml">
<form string="Send Fax">
<group>
<group>
<field name="partner_id"/>
<field name="fax_number"/>
</group>
<group>
<field name="generate_pdf"/>
<field name="sale_order_id" readonly="1"
invisible="not sale_order_id"/>
<field name="account_move_id" readonly="1"
invisible="not account_move_id"/>
</group>
</group>
<group>
<field name="cover_page_text"
placeholder="Optional cover page message..."/>
</group>
<!-- Ordered document list with drag handle and file upload -->
<separator string="Documents (drag to reorder)"/>
<field name="document_line_ids" nolabel="1">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="file_upload" filename="file_name"/>
<field name="file_name"/>
<field name="attachment_id" column_invisible="1"/>
<widget name="preview_button"/>
</list>
</field>
<footer>
<button name="action_send" string="Send Fax"
type="object" class="btn-primary"
icon="fa-fax"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Wizard action (for standalone use from menu) -->
<record id="action_send_fax_wizard" model="ir.actions.act_window">
<field name="name">Send Fax</field>
<field name="res_model">fusion_faxes.send.fax.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_send_fax"
name="Send Fax"
parent="menu_fusion_faxes_root"
action="action_send_fax_wizard"
sequence="5"/>
</odoo>

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
{
'name': 'Disable IAP Calls',
'version': '19.0.1.0.0',
'category': 'Tools',
'summary': 'Disables all IAP (In-App Purchase) external API calls',
'description': """
This module completely disables:
- IAP service calls to Odoo servers
- OCR/Extract API calls
- Lead enrichment API calls
- Any other external Odoo API communication
For local development use only.
""",
'author': 'Development',
'depends': ['iap'],
'data': [],
'installable': True,
'auto_install': True,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import iap_account

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Disable all IAP external API calls for local development
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class IapAccountDisabled(models.Model):
_inherit = 'iap.account'
@api.model
def get_credits(self, service_name):
"""
DISABLED: Return fake unlimited credits
"""
_logger.info("IAP get_credits DISABLED - returning unlimited credits for %s", service_name)
return 999999

View File

@@ -0,0 +1,143 @@
# Disable Odoo Online Services
**Version:** 18.0.1.0.0
**License:** LGPL-3
**Odoo Version:** 18.0
## Overview
This module comprehensively disables all external communications between your Odoo instance and Odoo's servers. It prevents:
- License/subscription checks
- User count reporting
- IAP (In-App Purchase) credit checks
- Publisher warranty communications
- Partner autocomplete/enrichment
- Expiration warnings in the UI
## Features
### 1. IAP JSON-RPC Blocking
Patches the core `iap_jsonrpc` function to prevent all IAP API calls:
- Returns fake successful responses
- Logs all blocked calls
- Provides unlimited credits for services that check
### 2. License Parameter Protection
Protects critical `ir.config_parameter` values:
- `database.expiration_date` → Always returns `2099-12-31 23:59:59`
- `database.expiration_reason` → Always returns `renewal`
- `database.enterprise_code` → Always returns `PERMANENT_LOCAL`
### 3. Session Info Patching
Modifies `session_info()` to prevent frontend warnings:
- Sets expiration date to 2099
- Sets `warning` to `False`
- Removes "already linked" subscription prompts
### 4. User Creation Protection
Logs user creation without triggering subscription checks:
- Blocks any external validation
- Logs permission changes
### 5. Publisher Warranty Block
Disables all warranty-related server communication:
- `_get_sys_logs()` → Returns empty response
- `update_notification()` → Returns success without calling server
### 6. Cron Job Blocking
Blocks scheduled actions that contact Odoo:
- Publisher Warranty Check
- Database Auto-Expiration Check
- Various IAP-related crons
## Installation
1. Copy the module to your Odoo addons directory
2. Restart Odoo
3. Go to Apps → Update Apps List
4. Search for "Disable Odoo Online Services"
5. Click Install
## Verification
Check that blocking is active:
```bash
docker logs odoo-container 2>&1 | grep -i "BLOCKED\|DISABLED"
```
Expected output:
```
IAP JSON-RPC calls have been DISABLED globally
Module update_list: Scanning local addons only (Odoo Apps store disabled)
Publisher warranty update_notification BLOCKED
Creating 1 user(s) - subscription check DISABLED
```
## Configuration
No configuration required. The module automatically:
- Sets permanent expiration values on install (via `_post_init_hook`)
- Patches all necessary functions when loaded
- Protects values from being changed
## Technical Details
### Files
| File | Purpose |
|------|---------|
| `models/disable_iap_tools.py` | Patches `iap_jsonrpc` globally |
| `models/disable_online_services.py` | Blocks publisher warranty, cron jobs |
| `models/disable_database_expiration.py` | Protects `ir.config_parameter` |
| `models/disable_session_leaks.py` | Patches session info, user creation |
| `models/disable_partner_autocomplete.py` | Blocks partner enrichment |
| `models/disable_all_external.py` | Additional external call blocks |
### Blocked Endpoints
All redirected to `http://localhost:65535`:
- `iap.endpoint`
- `publisher_warranty_url`
- `partner_autocomplete.endpoint`
- `iap_extract_endpoint`
- `olg.endpoint`
- `mail.media_library_endpoint`
- `sms.endpoint`
- `crm.iap_lead_mining.endpoint`
- And many more...
## Dependencies
- `base`
- `web`
- `iap`
- `mail`
- `base_setup`
## Compatibility
- Odoo 18.0 Community Edition
- Odoo 18.0 Enterprise Edition
## Disclaimer
This module is intended for legitimate use cases such as:
- Air-gapped environments
- Development/testing instances
- Self-hosted deployments with proper licensing
Ensure you comply with Odoo's licensing terms for your use case.
## Changelog
### 1.0.0 (2025-12-29)
- Initial release
- IAP blocking
- Publisher warranty blocking
- Session info patching
- User creation protection
- Config parameter protection

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
from . import models
def _post_init_hook(env):
"""
Set all configuration parameters to disable external Odoo services.
This runs after module installation.
"""
import logging
_logger = logging.getLogger(__name__)
set_param = env['ir.config_parameter'].sudo().set_param
# Set permanent database expiration
params_to_set = {
# Database license parameters
'database.expiration_date': '2099-12-31 23:59:59',
'database.expiration_reason': 'renewal',
'database.enterprise_code': 'PERMANENT_LOCAL',
# Clear "already linked" parameters
'database.already_linked_subscription_url': '',
'database.already_linked_email': '',
'database.already_linked_send_mail_url': '',
# Redirect all IAP endpoints to localhost
'iap.endpoint': 'http://localhost:65535',
'partner_autocomplete.endpoint': 'http://localhost:65535',
'iap_extract_endpoint': 'http://localhost:65535',
'olg.endpoint': 'http://localhost:65535',
'mail.media_library_endpoint': 'http://localhost:65535',
'website.api_endpoint': 'http://localhost:65535',
'sms.endpoint': 'http://localhost:65535',
'crm.iap_lead_mining.endpoint': 'http://localhost:65535',
'reveal.endpoint': 'http://localhost:65535',
'publisher_warranty_url': 'http://localhost:65535',
# OCN (Odoo Cloud Notification) - blocks push notifications to Odoo
'odoo_ocn.endpoint': 'http://localhost:65535', # Main OCN endpoint
'mail_mobile.enable_ocn': 'False', # Disable OCN push notifications
'odoo_ocn.project_id': '', # Clear any registered project
'ocn.uuid': '', # Clear OCN UUID to prevent registration
# Snailmail (physical mail service)
'snailmail.endpoint': 'http://localhost:65535',
# Social media IAP
'social.facebook_endpoint': 'http://localhost:65535',
'social.twitter_endpoint': 'http://localhost:65535',
'social.linkedin_endpoint': 'http://localhost:65535',
}
_logger.info("=" * 60)
_logger.info("DISABLE ODOO ONLINE: Setting configuration parameters")
_logger.info("=" * 60)
for key, value in params_to_set.items():
try:
set_param(key, value)
_logger.info("Set %s = %s", key, value if len(str(value)) < 30 else value[:30] + "...")
except Exception as e:
_logger.warning("Could not set %s: %s", key, e)
_logger.info("=" * 60)
_logger.info("DISABLE ODOO ONLINE: Configuration complete")
_logger.info("=" * 60)

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
{
'name': 'Disable Odoo Online Services',
'version': '19.0.1.0.0',
'category': 'Tools',
'summary': 'Blocks ALL external Odoo server communications',
'description': """
Comprehensive Module to Disable ALL Odoo Online Services
=========================================================
This module completely blocks all external communications from Odoo to Odoo's servers.
**Blocked Services:**
- Publisher Warranty checks (license validation)
- IAP (In-App Purchase) - All services
- Partner Autocomplete API
- Company Enrichment API
- VAT Lookup API
- SMS API
- Invoice/Expense OCR Extract
- Media Library (Stock Images)
- Currency Rate Live Updates
- CRM Lead Mining
- CRM Reveal (Website visitor identification)
- Google Calendar Sync
- AI/OLG Content Generation
- Database Registration
- Module Update checks from Odoo Store
- Session-based license detection
- Frontend expiration panel warnings
**Use Cases:**
- Air-gapped installations
- Local development without internet
- Enterprise deployments that don't want telemetry
- Testing environments
**WARNING:** This module disables legitimate Odoo services.
Only use if you understand the implications.
""",
'author': 'Fusion Development',
'website': 'https://fusiondevelopment.com',
'license': 'LGPL-3',
'depends': ['base', 'mail', 'web'],
'data': [
'data/disable_external_services.xml',
],
'assets': {
'web.assets_backend': [
'disable_odoo_online/static/src/js/disable_external_links.js',
],
},
'installable': True,
'auto_install': False,
'application': False,
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- All config parameters are set via post_init_hook in __init__.py -->
<!-- This file is kept for future data records if needed -->
</odoo>

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from . import disable_iap_tools # Patches iap_jsonrpc globally - MUST be first
from . import disable_http_requests # Patches requests library to block Odoo domains
from . import disable_online_services
from . import disable_partner_autocomplete
from . import disable_database_expiration
from . import disable_all_external
from . import disable_session_leaks

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""
Comprehensive blocking of ALL external Odoo service calls.
Only inherits from models that are guaranteed to exist in base Odoo.
"""
import logging
from odoo import api, models, fields
_logger = logging.getLogger(__name__)
# ============================================================
# Block Currency Rate Live Updates - Uses res.currency which always exists
# ============================================================
class ResCurrencyDisabled(models.Model):
_inherit = 'res.currency'
@api.model
def _get_rates_from_provider(self, provider, date):
"""DISABLED: Return empty rates."""
_logger.debug("Currency rate provider BLOCKED: provider=%s", provider)
return {}
# ============================================================
# Block Gravatar - Uses res.partner which always exists
# ============================================================
class ResPartnerDisabled(models.Model):
_inherit = 'res.partner'
@api.model
def _get_gravatar_image(self, email):
"""DISABLED: Return False to skip gravatar lookup."""
_logger.debug("Gravatar lookup BLOCKED for email=%s", email)
return False

View File

@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""
Disable database expiration checks and registration.
Consolidates all ir.config_parameter overrides.
"""
import logging
from datetime import datetime
from odoo import api, models, fields
_logger = logging.getLogger(__name__)
class IrConfigParameter(models.Model):
"""Override config parameters to prevent expiration and protect license values."""
_inherit = 'ir.config_parameter'
PROTECTED_PARAMS = {
'database.expiration_date': '2099-12-31 23:59:59',
'database.expiration_reason': 'renewal',
'database.enterprise_code': 'PERMANENT_LOCAL',
}
CLEAR_PARAMS = [
'database.already_linked_subscription_url',
'database.already_linked_email',
'database.already_linked_send_mail_url',
]
def init(self, force=False):
"""Set permanent valid subscription on module init."""
super().init(force=force)
self._set_permanent_subscription()
@api.model
def _set_permanent_subscription(self):
"""Set database to never expire."""
_logger.info("Setting permanent subscription values...")
for key, value in self.PROTECTED_PARAMS.items():
try:
self.env.cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
VALUES (%s, %s, %s, NOW() AT TIME ZONE 'UTC', %s, NOW() AT TIME ZONE 'UTC')
ON CONFLICT (key) DO UPDATE SET value = %s, write_date = NOW() AT TIME ZONE 'UTC'
""", (key, value, self.env.uid, self.env.uid, value))
except Exception as e:
_logger.debug("Could not set param %s: %s", key, e)
for key in self.CLEAR_PARAMS:
try:
self.env.cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
VALUES (%s, '', %s, NOW() AT TIME ZONE 'UTC', %s, NOW() AT TIME ZONE 'UTC')
ON CONFLICT (key) DO UPDATE SET value = '', write_date = NOW() AT TIME ZONE 'UTC'
""", (key, self.env.uid, self.env.uid))
except Exception as e:
_logger.debug("Could not clear param %s: %s", key, e)
@api.model
def get_param(self, key, default=False):
"""Override get_param to return permanent values for protected params."""
if key in self.PROTECTED_PARAMS:
return self.PROTECTED_PARAMS[key]
if key in self.CLEAR_PARAMS:
return ''
return super().get_param(key, default)
def set_param(self, key, value):
"""Override set_param to prevent external processes from changing protected values."""
if key in self.PROTECTED_PARAMS:
if value != self.PROTECTED_PARAMS[key]:
_logger.warning("Blocked attempt to change protected param %s to %s", key, value)
return True
if key in self.CLEAR_PARAMS:
value = ''
return super().set_param(key, value)
class DatabaseExpirationCheck(models.AbstractModel):
_name = 'disable.odoo.online.expiration'
_description = 'Database Expiration Blocker'
@api.model
def check_database_expiration(self):
return {
'valid': True,
'expiration_date': '2099-12-31 23:59:59',
'expiration_reason': 'renewal',
}
class Base(models.AbstractModel):
_inherit = 'base'
@api.model
def _get_database_expiration_date(self):
return datetime(2099, 12, 31, 23, 59, 59)
@api.model
def _check_database_enterprise_expiration(self):
return True

View File

@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""
Block ALL outgoing HTTP requests to Odoo-related domains.
This patches the requests library to intercept and block external calls.
"""
import logging
import requests
from functools import wraps
from urllib.parse import urlparse
_logger = logging.getLogger(__name__)
# Domains to block - all Odoo external services
BLOCKED_DOMAINS = [
'odoo.com',
'odoofin.com',
'odoo.sh',
'iap.odoo.com',
'iap-services.odoo.com',
'partner-autocomplete.odoo.com',
'iap-extract.odoo.com',
'iap-sms.odoo.com',
'upgrade.odoo.com',
'apps.odoo.com',
'production.odoofin.com',
'plaid.com',
'yodlee.com',
'gravatar.com',
'www.gravatar.com',
'secure.gravatar.com',
]
# Store original functions
_original_request = None
_original_get = None
_original_post = None
def _is_blocked_url(url):
"""Check if the URL should be blocked."""
if not url:
return False
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
for blocked in BLOCKED_DOMAINS:
if blocked in domain:
return True
except Exception:
pass
return False
def _blocked_request(method, url, **kwargs):
"""Intercept and block requests to Odoo domains."""
if _is_blocked_url(url):
_logger.warning("HTTP REQUEST BLOCKED: %s %s", method.upper(), url)
# Return a mock response
response = requests.models.Response()
response.status_code = 200
response._content = b'{}'
response.headers['Content-Type'] = 'application/json'
return response
return _original_request(method, url, **kwargs)
def _blocked_get(url, **kwargs):
"""Intercept and block GET requests."""
if _is_blocked_url(url):
_logger.warning("HTTP GET BLOCKED: %s", url)
response = requests.models.Response()
response.status_code = 200
response._content = b'{}'
response.headers['Content-Type'] = 'application/json'
return response
return _original_get(url, **kwargs)
def _blocked_post(url, **kwargs):
"""Intercept and block POST requests."""
if _is_blocked_url(url):
_logger.warning("HTTP POST BLOCKED: %s", url)
response = requests.models.Response()
response.status_code = 200
response._content = b'{}'
response.headers['Content-Type'] = 'application/json'
return response
return _original_post(url, **kwargs)
def patch_requests():
"""Monkey-patch requests library to block Odoo domains."""
global _original_request, _original_get, _original_post
try:
if _original_request is None:
_original_request = requests.Session.request
_original_get = requests.get
_original_post = requests.post
# Patch Session.request (catches most calls)
def patched_session_request(self, method, url, **kwargs):
if _is_blocked_url(url):
_logger.warning("HTTP SESSION REQUEST BLOCKED: %s %s", method.upper(), url)
response = requests.models.Response()
response.status_code = 200
response._content = b'{}'
response.headers['Content-Type'] = 'application/json'
response.request = requests.models.PreparedRequest()
response.request.url = url
response.request.method = method
return response
return _original_request(self, method, url, **kwargs)
requests.Session.request = patched_session_request
requests.get = _blocked_get
requests.post = _blocked_post
_logger.info("HTTP requests to Odoo domains have been BLOCKED")
_logger.info("Blocked domains: %s", ', '.join(BLOCKED_DOMAINS))
except Exception as e:
_logger.warning("Could not patch requests library: %s", e)
# Apply patch when module is imported
patch_requests()

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""
Override the core IAP tools to block ALL external API calls.
This is the master switch that blocks ALL Odoo external communications.
"""
import logging
from odoo import exceptions, _
_logger = logging.getLogger(__name__)
# Store original function reference
_original_iap_jsonrpc = None
def _disabled_iap_jsonrpc(url, method='call', params=None, timeout=15):
"""
DISABLED: Block all IAP JSON-RPC calls.
Returns empty/success response instead of making external calls.
"""
_logger.info("IAP JSONRPC BLOCKED: %s (method=%s)", url, method)
# Return appropriate empty responses based on the endpoint
if '/authorize' in url:
return 'fake_transaction_token_disabled'
elif '/capture' in url or '/cancel' in url:
return True
elif '/credits' in url:
return 999999
elif 'partner-autocomplete' in url:
return []
elif 'enrich' in url:
return {}
elif 'sms' in url:
_logger.warning("SMS API call blocked - SMS will not be sent")
return {'state': 'success', 'credits': 999999}
elif 'extract' in url:
return {'status': 'success', 'credits': 999999}
else:
return {}
def patch_iap_tools():
"""
Monkey-patch the iap_jsonrpc function to block external calls.
This is called when the module loads.
"""
global _original_iap_jsonrpc
try:
from odoo.addons.iap.tools import iap_tools
if _original_iap_jsonrpc is None:
_original_iap_jsonrpc = iap_tools.iap_jsonrpc
iap_tools.iap_jsonrpc = _disabled_iap_jsonrpc
_logger.info("IAP JSON-RPC calls have been DISABLED globally")
except ImportError:
_logger.debug("IAP module not installed, skipping patch")
except Exception as e:
_logger.warning("Could not patch IAP tools: %s", e)
# Apply patch when module is imported
patch_iap_tools()

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""
Disable various Odoo online services and external API calls.
"""
import logging
from odoo import api, models, fields
_logger = logging.getLogger(__name__)
class IrModuleModule(models.Model):
"""Disable module update checks from Odoo store."""
_inherit = 'ir.module.module'
@api.model
def update_list(self):
"""
Override to prevent fetching from Odoo Apps store.
Only scan local addons paths.
"""
_logger.info("Module update_list: Scanning local addons only (Odoo Apps store disabled)")
return super().update_list()
def button_immediate_upgrade(self):
"""Prevent upgrade attempts that might contact Odoo."""
_logger.info("Module upgrade: Processing locally only")
return super().button_immediate_upgrade()
class IrCron(models.Model):
"""Disable scheduled actions that contact Odoo servers."""
_inherit = 'ir.cron'
def _callback(self, cron_name, server_action_id):
"""
Override to block certain cron jobs that contact Odoo.
Odoo 19 signature: _callback(self, cron_name, server_action_id)
"""
blocked_crons = [
'publisher',
'warranty',
'update_notification',
'database_expiration',
'iap_enrich',
'ocr',
'Invoice OCR',
'enrich leads',
'fetchmail',
'online sync',
]
cron_lower = (cron_name or '').lower()
for blocked in blocked_crons:
if blocked.lower() in cron_lower:
_logger.info("Cron BLOCKED (external call): %s", cron_name)
return False
return super()._callback(cron_name, server_action_id)
class ResConfigSettings(models.TransientModel):
"""Override config settings to prevent external service configuration."""
_inherit = 'res.config.settings'
def set_values(self):
"""Ensure certain settings stay disabled."""
res = super().set_values()
# Disable any auto-update settings and set permanent expiration
params = self.env['ir.config_parameter'].sudo()
params.set_param('database.expiration_date', '2099-12-31 23:59:59')
params.set_param('database.expiration_reason', 'renewal')
params.set_param('database.enterprise_code', 'PERMANENT_LOCAL')
# Disable IAP endpoint (redirect to nowhere)
params.set_param('iap.endpoint', 'http://localhost:65535')
# Disable various external services
params.set_param('partner_autocomplete.endpoint', 'http://localhost:65535')
params.set_param('iap_extract_endpoint', 'http://localhost:65535')
params.set_param('olg.endpoint', 'http://localhost:65535')
params.set_param('mail.media_library_endpoint', 'http://localhost:65535')
return res
class PublisherWarrantyContract(models.AbstractModel):
"""Completely disable publisher warranty checks."""
_inherit = 'publisher_warranty.contract'
@api.model
def _get_sys_logs(self):
"""
DISABLED: Do not contact Odoo servers.
Returns fake successful response.
"""
_logger.info("Publisher warranty _get_sys_logs BLOCKED")
return {
'messages': [],
'enterprise_info': {
'expiration_date': '2099-12-31 23:59:59',
'expiration_reason': 'renewal',
'enterprise_code': 'PERMANENT_LOCAL',
}
}
@api.model
def _get_message(self):
"""DISABLED: Return empty message."""
_logger.info("Publisher warranty _get_message BLOCKED")
return {}
def update_notification(self, cron_mode=True):
"""
DISABLED: Do not send any data to Odoo servers.
Just update local parameters with permanent values.
"""
_logger.info("Publisher warranty update_notification BLOCKED")
# Set permanent valid subscription parameters
params = self.env['ir.config_parameter'].sudo()
params.set_param('database.expiration_date', '2099-12-31 23:59:59')
params.set_param('database.expiration_reason', 'renewal')
params.set_param('database.enterprise_code', 'PERMANENT_LOCAL')
# Clear any "already linked" parameters
params.set_param('database.already_linked_subscription_url', '')
params.set_param('database.already_linked_email', '')
params.set_param('database.already_linked_send_mail_url', '')
return True
class IrHttp(models.AbstractModel):
"""Block certain routes that call external services."""
_inherit = 'ir.http'
@classmethod
def _pre_dispatch(cls, rule, arguments):
"""Log and potentially block external service routes."""
# List of route patterns that should be blocked
blocked_routes = [
'/iap/',
'/partner_autocomplete/',
'/google_',
'/ocr/',
'/sms/',
]
# Note: We don't actually block here as it might break functionality
# The actual blocking happens at the API/model level
return super()._pre_dispatch(rule, arguments)

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""
Disable Partner Autocomplete external API calls.
"""
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
"""Disable partner autocomplete from Odoo API."""
_inherit = 'res.partner'
@api.model
def autocomplete(self, query, timeout=15):
"""
DISABLED: Return empty results instead of calling Odoo's partner API.
"""
_logger.debug("Partner autocomplete DISABLED - returning empty results for: %s", query)
return []
@api.model
def enrich_company(self, company_domain, partner_gid, vat, timeout=15):
"""
DISABLED: Return empty data instead of calling Odoo's enrichment API.
"""
_logger.debug("Partner enrichment DISABLED - returning empty for domain: %s", company_domain)
return {}
@api.model
def read_by_vat(self, vat, timeout=15):
"""
DISABLED: Return empty data instead of calling Odoo's VAT lookup API.
"""
_logger.debug("Partner VAT lookup DISABLED - returning empty for VAT: %s", vat)
return {}
class ResCompany(models.Model):
"""Disable company autocomplete features."""
_inherit = 'res.company'
@api.model
def autocomplete(self, query, timeout=15):
"""
DISABLED: Return empty results for company autocomplete.
"""
_logger.debug("Company autocomplete DISABLED - returning empty results")
return []

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""
Block session-based information leaks and frontend detection mechanisms.
Specifically targets the web_enterprise module's subscription checks.
"""
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class IrHttp(models.AbstractModel):
"""
Override session info to prevent frontend from detecting license status.
This specifically blocks web_enterprise's ExpirationPanel from showing.
"""
_inherit = 'ir.http'
def session_info(self):
"""
Override session info to set permanent valid subscription data.
This prevents the frontend ExpirationPanel from showing warnings.
Key overrides:
- expiration_date: Set to far future (2099)
- expiration_reason: Set to 'renewal' (valid subscription)
- warning: Set to False to hide all warning banners
"""
result = super().session_info()
# Override expiration-related session data
# These are read by enterprise_subscription_service.js
result['expiration_date'] = '2099-12-31 23:59:59'
result['expiration_reason'] = 'renewal'
result['warning'] = False # Critical: prevents warning banners
# Remove any "already linked" subscription info
# These could trigger redirect prompts
result.pop('already_linked_subscription_url', None)
result.pop('already_linked_email', None)
result.pop('already_linked_send_mail_url', None)
_logger.debug("Session info patched - expiration set to 2099, warnings disabled")
return result
class ResUsers(models.Model):
"""
Override user creation/modification to prevent subscription checks.
When users are created, Odoo Enterprise normally contacts Odoo servers
to verify the subscription allows that many users.
"""
_inherit = 'res.users'
@api.model_create_multi
def create(self, vals_list):
"""
Override create to ensure no external subscription check is triggered.
The actual check happens in publisher_warranty.contract which we've
already blocked, but this is an extra safety measure.
"""
_logger.info("Creating %d user(s) - subscription check DISABLED", len(vals_list))
# Create users normally - no external checks will happen
# because publisher_warranty.contract.update_notification is blocked
users = super().create(vals_list)
# Don't trigger any warranty checks
return users
def write(self, vals):
"""
Override write to log user modifications.
"""
result = super().write(vals)
# If internal user status changed, log it
if 'share' in vals or 'groups_id' in vals:
_logger.info("User permissions updated - subscription check DISABLED")
return result

View File

@@ -0,0 +1,38 @@
/** @odoo-module **/
/**
* This module intercepts clicks on external Odoo links to prevent
* referrer leakage when users click help/documentation/upgrade links.
*/
import { browser } from "@web/core/browser/browser";
// Store original window.open
const originalOpen = browser.open;
// Override browser.open to add referrer protection
browser.open = function(url, target, features) {
if (url && typeof url === 'string') {
const urlLower = url.toLowerCase();
// Check if it's an Odoo external link
const odooPatterns = [
'odoo.com',
'odoo.sh',
'accounts.odoo',
];
const isOdooLink = odooPatterns.some(pattern => urlLower.includes(pattern));
if (isOdooLink) {
// For Odoo links, open with noreferrer to prevent leaking your domain
const newWindow = originalOpen.call(this, url, target || '_blank', 'noopener,noreferrer');
return newWindow;
}
}
return originalOpen.call(this, url, target, features);
};
console.log('[disable_odoo_online] External link protection loaded');

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
{
'name': 'Disable Publisher Warranty',
'version': '19.0.1.0.0',
'category': 'Tools',
'summary': 'Disables all communication with Odoo publisher warranty servers',
'description': """
This module completely disables:
- Publisher warranty server communication
- Subscription expiration checks
- Automatic license updates
For local development use only.
""",
'author': 'Development',
'depends': ['mail'],
'data': [],
'installable': True,
'auto_install': True,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import publisher_warranty

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Disable all publisher warranty / subscription checks for local development
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class PublisherWarrantyContractDisabled(models.AbstractModel):
_inherit = "publisher_warranty.contract"
@api.model
def _get_sys_logs(self):
"""
DISABLED: Do not contact Odoo servers.
Returns fake successful response.
"""
_logger.info("Publisher warranty check DISABLED - not contacting Odoo servers")
return {
"messages": [],
"enterprise_info": {
"expiration_date": "2099-12-31 23:59:59",
"expiration_reason": "renewal",
"enterprise_code": self.env['ir.config_parameter'].sudo().get_param('database.enterprise_code', ''),
}
}
def update_notification(self, cron_mode=True):
"""
DISABLED: Do not send any data to Odoo servers.
Just update local parameters with permanent values.
"""
_logger.info("Publisher warranty update_notification DISABLED - no server contact")
# Set permanent valid subscription parameters
set_param = self.env['ir.config_parameter'].sudo().set_param
set_param('database.expiration_date', '2099-12-31 23:59:59')
set_param('database.expiration_reason', 'renewal')
# Clear any "already linked" parameters
set_param('database.already_linked_subscription_url', False)
set_param('database.already_linked_email', False)
set_param('database.already_linked_send_mail_url', False)
return True

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,721 @@
# 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*

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,100 @@
# -*- 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,
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import portal_main
from . import portal_assessment
from . import pdf_editor

View File

@@ -0,0 +1,218 @@
# -*- 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

View File

@@ -0,0 +1,42 @@
<?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 &amp; 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>

View File

@@ -0,0 +1,172 @@
<?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>

View File

@@ -0,0 +1,7 @@
<?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>

View File

@@ -0,0 +1,432 @@
<?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>

View File

@@ -0,0 +1,10 @@
# -*- 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

View File

@@ -0,0 +1,874 @@
# -*- coding: utf-8 -*-
import logging
import math
from datetime import timedelta
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionAccessibilityAssessment(models.Model):
_name = 'fusion.accessibility.assessment'
_description = 'Accessibility Assessment'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin']
_order = 'assessment_date desc, id desc'
_rec_name = 'display_name'
# ==========================================================================
# COMMON FIELDS (all assessment types)
# ==========================================================================
reference = fields.Char(
string='Reference',
readonly=True,
copy=False,
default=lambda self: _('New'),
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
assessment_type = fields.Selection(
selection=[
('stairlift_straight', 'Straight Stair Lift'),
('stairlift_curved', 'Curved Stair Lift'),
('vpl', 'Vertical Platform Lift'),
('ceiling_lift', 'Ceiling Lift'),
('ramp', 'Custom Ramp'),
('bathroom', 'Bathroom Modification'),
('tub_cutout', 'Tub Cutout'),
],
string='Assessment Type',
required=True,
tracking=True,
)
state = fields.Selection(
selection=[
('draft', 'Draft'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
],
string='Status',
default='draft',
tracking=True,
)
# Client Information
client_name = fields.Char(string='Client Name', required=True)
client_address = fields.Char(string='Address')
client_unit = fields.Char(string='Unit/Apt/Suite')
client_address_street = fields.Char(string='Street')
client_address_city = fields.Char(string='City')
client_address_province = fields.Char(string='Province')
client_address_postal = fields.Char(string='Postal Code')
client_phone = fields.Char(string='Phone')
client_email = fields.Char(string='Email')
# Booking fields
booking_source = fields.Selection(
selection=[
('phone_authorizer', 'Phone - Authorizer'),
('phone_client', 'Phone - Client'),
('walk_in', 'Walk-In'),
('portal', 'Online Booking'),
],
string='Booking Source',
default='phone_client',
help='How the assessment was booked',
)
modification_requested = fields.Text(
string='Modification Requested',
help='What the client or authorizer is looking for',
)
sms_confirmation_sent = fields.Boolean(
string='SMS Confirmation Sent',
default=False,
)
calendar_event_id = fields.Many2one(
'calendar.event',
string='Calendar Event',
readonly=True,
copy=False,
)
# Relationships
sales_rep_id = fields.Many2one(
'res.users',
string='Sales Rep',
default=lambda self: self.env.user,
tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer/OT',
tracking=True,
help='The Occupational Therapist or Authorizer for this assessment',
)
partner_id = fields.Many2one(
'res.partner',
string='Client Partner',
help='Linked partner record (created on completion)',
)
sale_order_id = fields.Many2one(
'sale.order',
string='Created Sale Order',
readonly=True,
copy=False,
)
# Dates
assessment_date = fields.Date(
string='Assessment Date',
default=fields.Date.today,
)
# General Notes
notes = fields.Text(string='General Notes')
# ==========================================================================
# STAIR LIFT - STRAIGHT FIELDS
# ==========================================================================
stair_steps = fields.Integer(string='Number of Steps')
stair_nose_to_nose = fields.Float(string='Nose to Nose Distance (inches)')
stair_side = fields.Selection(
selection=[('left', 'Left'), ('right', 'Right')],
string='Installation Side',
)
stair_style = fields.Selection(
selection=[
('standard', 'Standard Stair Lift'),
('slide_track', 'Slide Track Stair Lift'),
('foldable_hinge', 'Foldable Hinge Stair Lift'),
],
string='Stair Lift Style',
)
stair_power_swivel_upstairs = fields.Boolean(string='Power Swivel (Upstairs)')
stair_power_folding_footrest = fields.Boolean(string='Power Folding Footrest')
stair_calculated_length = fields.Float(
string='Calculated Track Length (inches)',
compute='_compute_stair_straight_length',
store=True,
)
stair_manual_length_override = fields.Float(string='Manual Length Override (inches)')
stair_final_length = fields.Float(
string='Final Track Length (inches)',
compute='_compute_stair_final_length',
store=True,
)
# ==========================================================================
# STAIR LIFT - CURVED FIELDS
# ==========================================================================
stair_curved_steps = fields.Integer(string='Number of Steps (Curved)')
stair_curves_count = fields.Integer(string='Number of Curves')
# Top Landing Options
stair_top_landing_type = fields.Selection(
selection=[
('none', 'Standard (No special landing)'),
('90_exit', '90° Exit'),
('90_parking', '90° Parking'),
('180_parking', '180° Parking'),
('flush_landing', 'Flush Landing'),
('vertical_overrun', 'Vertical Overrun (Custom)'),
],
string='Top Landing Type',
default='none',
help='Type of landing at the top of the staircase',
)
top_overrun_custom_length = fields.Float(
string='Top Overrun Length (inches)',
help='Custom overrun length when Vertical Overrun is selected',
)
# Bottom Landing Options
stair_bottom_landing_type = fields.Selection(
selection=[
('none', 'Standard (No special landing)'),
('90_park', '90° Park'),
('180_park', '180° Park'),
('drop_nose', 'Drop Nose Landing'),
('short_vertical', 'Short Vertical Start'),
('horizontal_overrun', 'Horizontal Overrun (Custom)'),
],
string='Bottom Landing Type',
default='none',
help='Type of landing at the bottom of the staircase',
)
bottom_overrun_custom_length = fields.Float(
string='Bottom Overrun Length (inches)',
help='Custom overrun length when Horizontal Overrun is selected',
)
# Legacy fields kept for backwards compatibility
stair_has_drop_nose = fields.Boolean(string='Has Drop Nose (Legacy)')
stair_parking_type = fields.Selection(
selection=[
('none', 'No Parking'),
('90_degree', '90° Parking (+2 feet)'),
('180_degree', '180° Parking (+4 feet)'),
],
string='Parking Type (Legacy)',
default='none',
)
stair_power_swivel_downstairs = fields.Boolean(string='Power Swivel (Downstairs)')
stair_auto_folding_footrest = fields.Boolean(string='Automatic Folding Footrest')
stair_auto_folding_hinge = fields.Boolean(string='Automatic Folding Hinge')
stair_auto_folding_seat = fields.Boolean(string='Automatic Folding Seat')
stair_custom_color = fields.Boolean(string='Customizable Colored Seat')
stair_additional_charging = fields.Boolean(string='Additional Charging Station')
stair_charging_with_remote = fields.Boolean(string='Charging Station with Remote')
stair_curved_calculated_length = fields.Float(
string='Calculated Track Length (inches)',
compute='_compute_stair_curved_length',
store=True,
)
stair_curved_manual_override = fields.Float(string='Manual Length Override (inches)')
stair_curved_final_length = fields.Float(
string='Final Track Length (inches)',
compute='_compute_stair_curved_final_length',
store=True,
)
# ==========================================================================
# VERTICAL PLATFORM LIFT (VPL) FIELDS
# ==========================================================================
vpl_room_width = fields.Float(string='Room Width (inches)')
vpl_room_depth = fields.Float(string='Room Depth (inches)')
vpl_rise_height = fields.Float(string='Total Rise Height (inches)')
vpl_has_existing_platform = fields.Boolean(string='Existing Platform Available')
vpl_concrete_depth = fields.Float(string='Concrete Depth (inches)', help='Minimum 4 inches required')
vpl_model_type = fields.Selection(
selection=[
('ac', 'AC Model (Dedicated 15-amp breaker required)'),
('dc', 'DC Model (No dedicated breaker required)'),
],
string='Model Type',
)
vpl_has_nearby_plug = fields.Boolean(string='Power Plug Nearby')
vpl_plug_specs = fields.Char(string='Plug Specifications', default='110V / 15-amp')
vpl_needs_plug_install = fields.Boolean(string='Needs Plug Installation')
vpl_needs_certification = fields.Boolean(string='Needs City Certification')
vpl_certification_notes = fields.Text(string='Certification Notes')
# ==========================================================================
# CEILING LIFT FIELDS
# ==========================================================================
ceiling_track_length = fields.Float(string='Total Track Length (feet)')
ceiling_movement_type = fields.Selection(
selection=[
('manual', 'Manual Movement (left-to-right)'),
('powered', 'Powered Movement (left-to-right)'),
],
string='Horizontal Movement Type',
help='All ceiling lifts move up/down with power. This is for left-to-right movement.',
)
ceiling_charging_throughout = fields.Boolean(
string='Charging Throughout Track',
help='Charging available throughout the track instead of one location',
)
ceiling_carry_bar = fields.Boolean(string='Carry Bar')
ceiling_additional_slings = fields.Integer(string='Additional Slings Needed')
# ==========================================================================
# CUSTOM RAMP FIELDS
# ==========================================================================
ramp_height = fields.Float(string='Total Height (inches from ground)')
ramp_ground_incline = fields.Float(string='Ground Incline (degrees)', help='Optional - if ground is inclined')
ramp_at_door = fields.Boolean(string='Ramp at Door', help='Requires 5ft landing at door')
ramp_calculated_length = fields.Float(
string='Calculated Ramp Length (inches)',
compute='_compute_ramp_length',
store=True,
help='Ontario Building Code: 12 inches length per 1 inch height',
)
ramp_landings_needed = fields.Integer(
string='Landings Needed',
compute='_compute_ramp_landings',
store=True,
help='Landing required every 30 feet (minimum 5 feet each)',
)
ramp_total_length = fields.Float(
string='Total Length with Landings (inches)',
compute='_compute_ramp_total_length',
store=True,
)
ramp_handrail_height = fields.Float(
string='Handrail Height (inches)',
default=32.0,
help='Minimum 32 inches required',
)
ramp_manual_override = fields.Float(string='Manual Length Override (inches)')
# ==========================================================================
# BATHROOM MODIFICATION FIELDS
# ==========================================================================
bathroom_description = fields.Text(
string='Modification Description',
help='Describe all bathroom modifications needed',
)
# ==========================================================================
# TUB CUTOUT FIELDS
# ==========================================================================
tub_internal_height = fields.Float(string='Internal Height of Tub (inches)')
tub_external_height = fields.Float(string='External Height of Tub (inches)')
tub_additional_supplies = fields.Text(string='Additional Supplies Needed')
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================
@api.depends('reference', 'assessment_type', 'client_name')
def _compute_display_name(self):
type_labels = dict(self._fields['assessment_type'].selection)
for rec in self:
type_label = type_labels.get(rec.assessment_type, '')
rec.display_name = f"{rec.reference or 'New'} - {type_label} - {rec.client_name or ''}"
@api.depends('stair_steps', 'stair_nose_to_nose')
def _compute_stair_straight_length(self):
"""Straight stair lift: (steps × nose_to_nose) + 13" top landing"""
for rec in self:
if rec.stair_steps and rec.stair_nose_to_nose:
rec.stair_calculated_length = (rec.stair_steps * rec.stair_nose_to_nose) + 13
else:
rec.stair_calculated_length = 0
@api.depends('stair_calculated_length', 'stair_manual_length_override')
def _compute_stair_final_length(self):
"""Use manual override if provided, otherwise use calculated"""
for rec in self:
if rec.stair_manual_length_override:
rec.stair_final_length = rec.stair_manual_length_override
else:
rec.stair_final_length = rec.stair_calculated_length
@api.depends('stair_curved_steps', 'stair_curves_count',
'stair_top_landing_type', 'stair_bottom_landing_type',
'top_overrun_custom_length', 'bottom_overrun_custom_length')
def _compute_stair_curved_length(self):
"""Curved stair lift calculation:
- 12" per step
- 16" per curve
- Top landing type additions (or custom overrun)
- Bottom landing type additions (or custom overrun)
"""
# Track length additions for each landing type (in inches)
# Note: vertical_overrun and horizontal_overrun use custom lengths
TOP_LANDING_LENGTHS = {
'none': 0,
'90_exit': 24, # 2 feet
'90_parking': 24, # 2 feet
'180_parking': 48, # 4 feet
'flush_landing': 12, # 1 foot
}
BOTTOM_LANDING_LENGTHS = {
'none': 0,
'90_park': 24, # 2 feet
'180_park': 48, # 4 feet
'drop_nose': 12, # 1 foot
'short_vertical': 12, # 1 foot
}
for rec in self:
if rec.stair_curved_steps:
base_length = rec.stair_curved_steps * 12 # 12" per step
curves_length = (rec.stair_curves_count or 0) * 16 # 16" per curve
# Top landing length - use custom if overrun selected
if rec.stair_top_landing_type == 'vertical_overrun':
top_landing = rec.top_overrun_custom_length or 0
else:
top_landing = TOP_LANDING_LENGTHS.get(rec.stair_top_landing_type or 'none', 0)
# Bottom landing length - use custom if overrun selected
if rec.stair_bottom_landing_type == 'horizontal_overrun':
bottom_landing = rec.bottom_overrun_custom_length or 0
else:
bottom_landing = BOTTOM_LANDING_LENGTHS.get(rec.stair_bottom_landing_type or 'none', 0)
rec.stair_curved_calculated_length = (
base_length + curves_length + top_landing + bottom_landing
)
else:
rec.stair_curved_calculated_length = 0
@api.depends('stair_curved_calculated_length', 'stair_curved_manual_override')
def _compute_stair_curved_final_length(self):
"""Use manual override if provided, otherwise use calculated"""
for rec in self:
if rec.stair_curved_manual_override:
rec.stair_curved_final_length = rec.stair_curved_manual_override
else:
rec.stair_curved_final_length = rec.stair_curved_calculated_length
@api.depends('ramp_height')
def _compute_ramp_length(self):
"""Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)"""
for rec in self:
if rec.ramp_height:
rec.ramp_calculated_length = rec.ramp_height * 12
else:
rec.ramp_calculated_length = 0
@api.depends('ramp_calculated_length')
def _compute_ramp_landings(self):
"""Landing required every 30 feet (360 inches)"""
for rec in self:
if rec.ramp_calculated_length:
# Calculate how many landings are needed (every 30 feet = 360 inches)
rec.ramp_landings_needed = math.ceil(rec.ramp_calculated_length / 360)
else:
rec.ramp_landings_needed = 0
@api.depends('ramp_calculated_length', 'ramp_landings_needed', 'ramp_at_door')
def _compute_ramp_total_length(self):
"""Total length including landings (5 feet = 60 inches each)"""
for rec in self:
base_length = rec.ramp_calculated_length or 0
landings_length = (rec.ramp_landings_needed or 0) * 60 # 5 feet per landing
door_landing = 60 if rec.ramp_at_door else 0 # 5 feet at door
rec.ramp_total_length = base_length + landings_length + door_landing
# ==========================================================================
# CRUD METHODS
# ==========================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('reference', _('New')) == _('New'):
vals['reference'] = self.env['ir.sequence'].next_by_code(
'fusion.accessibility.assessment'
) or _('New')
return super().create(vals_list)
# ==========================================================================
# BUSINESS LOGIC
# ==========================================================================
def action_complete(self):
"""Complete the assessment and create a Sale Order"""
self.ensure_one()
if not self.client_name:
raise UserError(_('Please enter the client name.'))
# Create or find partner
partner = self._ensure_partner()
# Create draft sale order
sale_order = self._create_draft_sale_order(partner)
# Add tag based on assessment type
self._add_assessment_tag(sale_order)
# Copy photos from assessment to sale order chatter
self._copy_photos_to_sale_order(sale_order)
# Update state
self.write({
'state': 'completed',
'sale_order_id': sale_order.id,
'partner_id': partner.id,
})
# Send email notification to office
self._send_completion_email(sale_order)
# Schedule follow-up activity for sales rep
self._schedule_followup_activity(sale_order)
_logger.info(f"Completed accessibility assessment {self.reference}, created SO {sale_order.name}")
return sale_order
def _add_assessment_tag(self, sale_order):
"""Add a tag to the sale order based on assessment type"""
self.ensure_one()
# Map assessment types to tag names (ALL CAPS)
tag_map = {
'stairlift_straight': 'STRAIGHT STAIR LIFT',
'stairlift_curved': 'CURVED STAIR LIFT',
'vpl': 'VERTICAL PLATFORM LIFT',
'ceiling_lift': 'CEILING LIFT',
'ramp': 'CUSTOM RAMP',
'bathroom': 'BATHROOM MODIFICATION',
'tub_cutout': 'TUB CUTOUT',
}
tag_name = tag_map.get(self.assessment_type)
if not tag_name:
return
# Find or create the tag
Tag = self.env['crm.tag'].sudo()
tag = Tag.search([('name', '=', tag_name)], limit=1)
if not tag:
tag = Tag.create({'name': tag_name})
_logger.info(f"Created new tag: {tag_name}")
# Add tag to sale order
if hasattr(sale_order, 'tag_ids'):
sale_order.write({'tag_ids': [(4, tag.id)]})
_logger.info(f"Added tag '{tag_name}' to SO {sale_order.name}")
def _copy_photos_to_sale_order(self, sale_order):
"""Copy assessment photos to sale order chatter"""
self.ensure_one()
Attachment = self.env['ir.attachment'].sudo()
# Find photos attached to this assessment
photos = Attachment.search([
('res_model', '=', 'fusion.accessibility.assessment'),
('res_id', '=', self.id),
('mimetype', 'like', 'image/%'),
])
if not photos:
return
# Copy attachments to sale order and post in chatter
attachment_ids = []
for photo in photos:
new_attachment = photo.copy({
'res_model': 'sale.order',
'res_id': sale_order.id,
})
attachment_ids.append(new_attachment.id)
if attachment_ids:
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
sale_order.message_post(
body=Markup(f'''
<div class="alert alert-secondary">
<strong><i class="fa fa-camera"></i> Assessment Photos</strong><br/>
{len(attachment_ids)} photo(s) from {type_label} Assessment ({self.reference})
</div>
'''),
message_type='comment',
subtype_xmlid='mail.mt_note',
attachment_ids=attachment_ids,
)
_logger.info(f"Copied {len(attachment_ids)} photos to SO {sale_order.name}")
def _send_completion_email(self, sale_order):
"""Send email notification to office about assessment completion"""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
# Check if email notifications are enabled
if not ICP.get_param('fusion_claims.enable_email_notifications', 'True') == 'True':
return
# Get office notification emails from company
company = self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
email_list = [p.email for p in office_partners if p.email]
office_emails = ', '.join(email_list)
if not office_emails:
_logger.warning("No office notification recipients configured for accessibility assessment completion")
return
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
body = self._email_build(
title='Accessibility Assessment Completed',
summary=f'A new {type_label.lower()} assessment has been completed for '
f'<strong>{self.client_name}</strong>. A sale order has been created.',
email_type='info',
sections=[('Assessment Details', [
('Type', type_label),
('Reference', self.reference),
('Client', self.client_name),
('Sales Rep', self.sales_rep_id.name if self.sales_rep_id else 'N/A'),
('Sale Order', sale_order.name),
])],
button_url=f'{sale_order.get_base_url()}/web#id={sale_order.id}&model=sale.order&view_type=form',
button_text='View Sale Order',
)
# Send email
mail_values = {
'subject': f'Accessibility Assessment Completed: {type_label} - {self.client_name}',
'body_html': body,
'email_to': office_emails,
'email_from': self.env.company.email or 'noreply@example.com',
}
try:
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send()
_logger.info(f"Sent accessibility assessment completion email to {office_emails}")
except Exception as e:
_logger.error(f"Failed to send assessment completion email: {e}")
def _schedule_followup_activity(self, sale_order):
"""Schedule a follow-up activity for the sales rep"""
self.ensure_one()
if not self.sales_rep_id:
return
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
# Get the "To Do" activity type
activity_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
if not activity_type:
_logger.warning("Could not find 'To Do' activity type")
return
# Schedule activity for tomorrow
due_date = fields.Date.today() + timedelta(days=1)
try:
sale_order.activity_schedule(
activity_type_id=activity_type.id,
date_deadline=due_date,
user_id=self.sales_rep_id.id,
summary=f'Follow up on {type_label} Assessment',
note=f'Assessment {self.reference} for {self.client_name} has been completed. Please follow up with the client.',
)
_logger.info(f"Scheduled follow-up activity for {self.sales_rep_id.name} on SO {sale_order.name}")
except Exception as e:
_logger.error(f"Failed to schedule follow-up activity: {e}")
def _ensure_partner(self):
"""Find or create a partner for the client"""
self.ensure_one()
Partner = self.env['res.partner'].sudo()
# First, try to find existing partner by email
if self.client_email:
existing = Partner.search([('email', '=ilike', self.client_email)], limit=1)
if existing:
return existing
# Create new partner
partner_vals = {
'name': self.client_name,
'email': self.client_email,
'phone': self.client_phone,
'street': self.client_address_street or self.client_address,
'street2': self.client_unit or False,
'city': self.client_address_city,
'zip': self.client_address_postal,
'customer_rank': 1,
}
# Set province/state if provided
if self.client_address_province:
state = self.env['res.country.state'].sudo().search([
('code', '=ilike', self.client_address_province),
('country_id.code', '=', 'CA'),
], limit=1)
if state:
partner_vals['state_id'] = state.id
partner_vals['country_id'] = state.country_id.id
else:
# Default to Canada
canada = self.env.ref('base.ca', raise_if_not_found=False)
if canada:
partner_vals['country_id'] = canada.id
partner = Partner.create(partner_vals)
_logger.info(f"Created partner {partner.name} from accessibility assessment {self.reference}")
return partner
def _create_draft_sale_order(self, partner):
"""Create a draft sale order from the assessment"""
self.ensure_one()
SaleOrder = self.env['sale.order'].sudo()
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Accessibility')
so_vals = {
'partner_id': partner.id,
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
'state': 'draft',
'origin': f'Accessibility: {self.reference} ({type_label})',
'x_fc_sale_type': 'direct_private', # Accessibility items typically private pay
}
sale_order = SaleOrder.create(so_vals)
_logger.info(f"Created draft sale order {sale_order.name} from accessibility assessment {self.reference}")
# Post assessment details to chatter
assessment_html = self._format_assessment_html_table()
sale_order.message_post(
body=Markup(assessment_html),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
return sale_order
def _format_assessment_html_table(self):
"""Format assessment details as HTML for chatter"""
type_labels = dict(self._fields['assessment_type'].selection)
type_label = type_labels.get(self.assessment_type, 'Unknown')
html = f'''
<div class="alert alert-info" role="alert">
<h5 class="alert-heading"><i class="fa fa-wheelchair"></i> Accessibility Assessment: {type_label}</h5>
<p><strong>Reference:</strong> {self.reference}<br/>
<strong>Client:</strong> {self.client_name}<br/>
<strong>Address:</strong> {self.client_address or 'N/A'}<br/>
<strong>Date:</strong> {self.assessment_date}</p>
'''
# Add type-specific details
if self.assessment_type == 'stairlift_straight':
html += f'''
<hr>
<p><strong>Straight Stair Lift Details:</strong></p>
<ul>
<li>Steps: {self.stair_steps or 'N/A'}</li>
<li>Nose to Nose: {self.stair_nose_to_nose or 0}" per step</li>
<li>Installation Side: {self.stair_side or 'N/A'}</li>
<li>Style: {dict(self._fields['stair_style'].selection or {}).get(self.stair_style, 'N/A')}</li>
<li>Calculated Track Length: {self.stair_calculated_length:.1f}"</li>
<li>Final Track Length: {self.stair_final_length:.1f}"</li>
</ul>
<p><strong>Features:</strong></p>
<ul>
{'<li>Power Swivel (Upstairs)</li>' if self.stair_power_swivel_upstairs else ''}
{'<li>Power Folding Footrest</li>' if self.stair_power_folding_footrest else ''}
</ul>
'''
elif self.assessment_type == 'stairlift_curved':
# Format landing types for display
top_landing_display = dict(self._fields['stair_top_landing_type'].selection or {}).get(self.stair_top_landing_type, 'Standard')
bottom_landing_display = dict(self._fields['stair_bottom_landing_type'].selection or {}).get(self.stair_bottom_landing_type, 'Standard')
# Add custom overrun values if applicable
if self.stair_top_landing_type == 'vertical_overrun' and self.top_overrun_custom_length:
top_landing_display += f' ({self.top_overrun_custom_length:.1f}")'
if self.stair_bottom_landing_type == 'horizontal_overrun' and self.bottom_overrun_custom_length:
bottom_landing_display += f' ({self.bottom_overrun_custom_length:.1f}")'
html += f'''
<hr>
<p><strong>Curved Stair Lift Details:</strong></p>
<ul>
<li>Steps: {self.stair_curved_steps or 'N/A'}</li>
<li>Number of Curves: {self.stair_curves_count or 0}</li>
<li>Top Landing: {top_landing_display}</li>
<li>Bottom Landing: {bottom_landing_display}</li>
<li>Calculated Track Length: {self.stair_curved_calculated_length:.1f}"</li>
<li>Final Track Length: {self.stair_curved_final_length:.1f}"</li>
</ul>
<p><strong>Features:</strong></p>
<ul>
{'<li>Power Swivel (Upstairs)</li>' if self.stair_power_swivel_upstairs else ''}
{'<li>Power Swivel (Downstairs)</li>' if self.stair_power_swivel_downstairs else ''}
{'<li>Auto Folding Footrest</li>' if self.stair_auto_folding_footrest else ''}
{'<li>Auto Folding Hinge</li>' if self.stair_auto_folding_hinge else ''}
{'<li>Auto Folding Seat</li>' if self.stair_auto_folding_seat else ''}
{'<li>Customizable Color</li>' if self.stair_custom_color else ''}
{'<li>Additional Charging Station</li>' if self.stair_additional_charging else ''}
{'<li>Charging with Remote</li>' if self.stair_charging_with_remote else ''}
</ul>
'''
elif self.assessment_type == 'vpl':
html += f'''
<hr>
<p><strong>Vertical Platform Lift Details:</strong></p>
<ul>
<li>Room Dimensions: {self.vpl_room_width or 0}" W x {self.vpl_room_depth or 0}" D</li>
<li>Rise Height: {self.vpl_rise_height or 0}"</li>
<li>Existing Platform: {'Yes' if self.vpl_has_existing_platform else 'No'}</li>
<li>Concrete Depth: {self.vpl_concrete_depth or 0}" (min 4" required)</li>
<li>Model Type: {dict(self._fields['vpl_model_type'].selection or {}).get(self.vpl_model_type, 'N/A')}</li>
<li>Power Plug Nearby: {'Yes' if self.vpl_has_nearby_plug else 'No'}</li>
<li>Needs Plug Installation: {'Yes' if self.vpl_needs_plug_install else 'No'}</li>
<li>Needs Certification: {'Yes' if self.vpl_needs_certification else 'No'}</li>
</ul>
'''
elif self.assessment_type == 'ceiling_lift':
html += f'''
<hr>
<p><strong>Ceiling Lift Details:</strong></p>
<ul>
<li>Track Length: {self.ceiling_track_length or 0} feet</li>
<li>Movement Type: {dict(self._fields['ceiling_movement_type'].selection or {}).get(self.ceiling_movement_type, 'N/A')}</li>
<li>Charging Throughout Track: {'Yes' if self.ceiling_charging_throughout else 'No'}</li>
<li>Carry Bar: {'Yes' if self.ceiling_carry_bar else 'No'}</li>
<li>Additional Slings: {self.ceiling_additional_slings or 0}</li>
</ul>
'''
elif self.assessment_type == 'ramp':
html += f'''
<hr>
<p><strong>Custom Ramp Details:</strong></p>
<ul>
<li>Height: {self.ramp_height or 0}" from ground</li>
<li>Ground Incline: {self.ramp_ground_incline or 0}°</li>
<li>At Door: {'Yes (5ft landing required)' if self.ramp_at_door else 'No'}</li>
<li>Calculated Ramp Length: {self.ramp_calculated_length:.1f}" ({self.ramp_calculated_length/12:.1f} ft)</li>
<li>Landings Needed: {self.ramp_landings_needed or 0} (5ft each)</li>
<li>Total Length with Landings: {self.ramp_total_length:.1f}" ({self.ramp_total_length/12:.1f} ft)</li>
<li>Handrail Height: {self.ramp_handrail_height or 32}"</li>
</ul>
'''
elif self.assessment_type == 'bathroom':
html += f'''
<hr>
<p><strong>Bathroom Modification Description:</strong></p>
<p>{self.bathroom_description or 'No description provided.'}</p>
'''
elif self.assessment_type == 'tub_cutout':
html += f'''
<hr>
<p><strong>Tub Cutout Details:</strong></p>
<ul>
<li>Internal Height: {self.tub_internal_height or 0}"</li>
<li>External Height: {self.tub_external_height or 0}"</li>
</ul>
<p><strong>Additional Supplies:</strong></p>
<p>{self.tub_additional_supplies or 'None specified.'}</p>
'''
# Add general notes
if self.notes:
html += f'''
<hr>
<p><strong>Notes:</strong></p>
<p>{self.notes}</p>
'''
html += '</div>'
return html
def action_cancel(self):
"""Cancel the assessment"""
self.ensure_one()
self.write({'state': 'cancelled'})
def action_reset_to_draft(self):
"""Reset to draft state"""
self.ensure_one()
self.write({'state': 'draft'})

View File

@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
import base64
import logging
_logger = logging.getLogger(__name__)
class ADPDocument(models.Model):
_name = 'fusion.adp.document'
_description = 'ADP Application Document'
_order = 'upload_date desc, revision desc'
_rec_name = 'display_name'
# Relationships
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='cascade',
index=True,
)
assessment_id = fields.Many2one(
'fusion.assessment',
string='Assessment',
ondelete='cascade',
index=True,
)
# Document Type
document_type = fields.Selection([
('full_application', 'Full ADP Application (14 pages)'),
('pages_11_12', 'Pages 11 & 12 (Signature Pages)'),
('page_11', 'Page 11 Only (Authorizer Signature)'),
('page_12', 'Page 12 Only (Client Signature)'),
('submitted_final', 'Final Submitted Application'),
('assessment_report', 'Assessment Report'),
('assessment_signed', 'Signed Pages from Assessment'),
('other', 'Other Document'),
], string='Document Type', required=True, default='full_application')
# File Data
file = fields.Binary(
string='File',
required=True,
attachment=True,
)
filename = fields.Char(
string='Filename',
required=True,
)
file_size = fields.Integer(
string='File Size (bytes)',
compute='_compute_file_size',
store=True,
)
mimetype = fields.Char(
string='MIME Type',
default='application/pdf',
)
# Revision Tracking
revision = fields.Integer(
string='Revision',
default=1,
readonly=True,
)
revision_note = fields.Text(
string='Revision Note',
help='Notes about what changed in this revision',
)
is_current = fields.Boolean(
string='Is Current Version',
default=True,
index=True,
)
# Upload Information
uploaded_by = fields.Many2one(
'res.users',
string='Uploaded By',
default=lambda self: self.env.user,
readonly=True,
)
upload_date = fields.Datetime(
string='Upload Date',
default=fields.Datetime.now,
readonly=True,
)
source = fields.Selection([
('authorizer', 'Authorizer Portal'),
('sales_rep', 'Sales Rep Portal'),
('internal', 'Internal User'),
('assessment', 'Assessment Form'),
], string='Source', default='internal')
# Display
display_name = fields.Char(
string='Display Name',
compute='_compute_display_name',
store=True,
)
@api.depends('file')
def _compute_file_size(self):
for doc in self:
if doc.file:
doc.file_size = len(base64.b64decode(doc.file))
else:
doc.file_size = 0
@api.depends('document_type', 'filename', 'revision')
def _compute_display_name(self):
type_labels = dict(self._fields['document_type'].selection)
for doc in self:
type_label = type_labels.get(doc.document_type, doc.document_type)
doc.display_name = f"{type_label} - v{doc.revision} ({doc.filename or 'No file'})"
@api.model_create_multi
def create(self, vals_list):
"""Override create to handle revision numbering"""
for vals in vals_list:
# Find existing documents of the same type for the same order/assessment
domain = [('document_type', '=', vals.get('document_type'))]
if vals.get('sale_order_id'):
domain.append(('sale_order_id', '=', vals.get('sale_order_id')))
if vals.get('assessment_id'):
domain.append(('assessment_id', '=', vals.get('assessment_id')))
existing = self.search(domain, order='revision desc', limit=1)
if existing:
# Mark existing as not current and increment revision
existing.is_current = False
vals['revision'] = existing.revision + 1
else:
vals['revision'] = 1
vals['is_current'] = True
return super().create(vals_list)
def action_download(self):
"""Download the document"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{self._name}/{self.id}/file/{self.filename}?download=true',
'target': 'self',
}
def get_document_url(self):
"""Get the download URL for portal access"""
self.ensure_one()
return f'/my/authorizer/document/{self.id}/download'
@api.model
def get_documents_for_order(self, sale_order_id, document_type=None, current_only=True):
"""Get documents for a sale order, optionally filtered by type"""
domain = [('sale_order_id', '=', sale_order_id)]
if document_type:
domain.append(('document_type', '=', document_type))
if current_only:
domain.append(('is_current', '=', True))
return self.search(domain, order='document_type, revision desc')
@api.model
def get_revision_history(self, sale_order_id, document_type):
"""Get all revisions of a specific document type"""
return self.search([
('sale_order_id', '=', sale_order_id),
('document_type', '=', document_type),
], order='revision desc')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
import logging
_logger = logging.getLogger(__name__)
class AuthorizerComment(models.Model):
_name = 'fusion.authorizer.comment'
_description = 'Authorizer/Sales Rep Comment'
_order = 'create_date desc'
_rec_name = 'display_name'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
ondelete='cascade',
index=True,
)
assessment_id = fields.Many2one(
'fusion.assessment',
string='Assessment',
ondelete='cascade',
index=True,
)
author_id = fields.Many2one(
'res.partner',
string='Author',
required=True,
default=lambda self: self.env.user.partner_id,
index=True,
)
author_user_id = fields.Many2one(
'res.users',
string='Author User',
default=lambda self: self.env.user,
index=True,
)
comment = fields.Text(
string='Comment',
required=True,
)
comment_type = fields.Selection([
('general', 'General Comment'),
('question', 'Question'),
('update', 'Status Update'),
('internal', 'Internal Note'),
], string='Type', default='general')
is_internal = fields.Boolean(
string='Internal Only',
default=False,
help='If checked, this comment will not be visible to portal users',
)
display_name = fields.Char(
string='Display Name',
compute='_compute_display_name',
store=True,
)
@api.depends('author_id', 'create_date')
def _compute_display_name(self):
for comment in self:
if comment.author_id and comment.create_date:
comment.display_name = f"{comment.author_id.name} - {comment.create_date.strftime('%Y-%m-%d %H:%M')}"
else:
comment.display_name = _('New Comment')
@api.model_create_multi
def create(self, vals_list):
"""Override create to set author from current user if not provided"""
for vals in vals_list:
if not vals.get('author_id'):
vals['author_id'] = self.env.user.partner_id.id
if not vals.get('author_user_id'):
vals['author_user_id'] = self.env.user.id
return super().create(vals_list)

View File

@@ -0,0 +1,328 @@
# -*- coding: utf-8 -*-
# Fusion PDF Template Engine
# Generic system for filling any funding agency's PDF forms
import base64
import logging
from io import BytesIO
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionPdfTemplate(models.Model):
_name = 'fusion.pdf.template'
_description = 'PDF Form Template'
_order = 'category, name'
name = fields.Char(string='Template Name', required=True)
category = fields.Selection([
('adp', 'ADP - Assistive Devices Program'),
('mod', 'March of Dimes'),
('odsp', 'ODSP'),
('hardship', 'Hardship Funding'),
('other', 'Other'),
], string='Funding Agency', required=True, default='adp')
version = fields.Char(string='Form Version', default='1.0')
state = fields.Selection([
('draft', 'Draft'),
('active', 'Active'),
('archived', 'Archived'),
], string='Status', default='draft', tracking=True)
# The actual PDF template file
pdf_file = fields.Binary(string='PDF Template', required=True, attachment=True)
pdf_filename = fields.Char(string='PDF Filename')
page_count = fields.Integer(
string='Page Count',
compute='_compute_page_count',
store=True,
)
# Page preview images for the visual editor
preview_ids = fields.One2many(
'fusion.pdf.template.preview', 'template_id',
string='Page Previews',
)
# Field positions configured via the visual editor
field_ids = fields.One2many(
'fusion.pdf.template.field', 'template_id',
string='Template Fields',
)
field_count = fields.Integer(
string='Fields',
compute='_compute_field_count',
)
notes = fields.Text(
string='Notes',
help='Usage notes, which assessments/forms use this template',
)
def write(self, vals):
res = super().write(vals)
if 'pdf_file' in vals and vals['pdf_file']:
for rec in self:
try:
rec.action_generate_previews()
except Exception as e:
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
return res
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
if rec.pdf_file:
try:
rec.action_generate_previews()
except Exception as e:
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
return records
@api.depends('pdf_file')
def _compute_page_count(self):
for rec in self:
if rec.pdf_file:
try:
from odoo.tools.pdf import PdfFileReader
pdf_data = base64.b64decode(rec.pdf_file)
reader = PdfFileReader(BytesIO(pdf_data))
rec.page_count = reader.getNumPages()
except Exception as e:
_logger.warning("Could not read PDF page count: %s", e)
rec.page_count = 0
else:
rec.page_count = 0
def action_generate_previews(self):
"""Generate PNG preview images from the PDF using poppler (pdftoppm).
Falls back gracefully if the PDF is protected or poppler is not available.
"""
self.ensure_one()
if not self.pdf_file:
raise UserError(_('Please upload a PDF file first.'))
import subprocess
import tempfile
import os
pdf_data = base64.b64decode(self.pdf_file)
try:
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, 'template.pdf')
with open(pdf_path, 'wb') as f:
f.write(pdf_data)
# Use pdftoppm to convert each page to PNG
result = subprocess.run(
['pdftoppm', '-png', '-r', '200', pdf_path, os.path.join(tmpdir, 'page')],
capture_output=True, timeout=30,
)
if result.returncode != 0:
stderr = result.stderr.decode('utf-8', errors='replace')
_logger.warning("pdftoppm failed: %s", stderr)
raise UserError(_(
'Could not generate previews automatically. '
'The PDF may be protected. Please upload preview images manually '
'in the Page Previews tab (screenshots of each page).'
))
# Find generated PNG files
png_files = sorted([
f for f in os.listdir(tmpdir)
if f.startswith('page-') and f.endswith('.png')
])
if not png_files:
raise UserError(_('No pages were generated. Please upload preview images manually.'))
# Delete existing previews
self.preview_ids.unlink()
# Create preview records
for idx, png_file in enumerate(png_files):
png_path = os.path.join(tmpdir, png_file)
with open(png_path, 'rb') as f:
image_data = base64.b64encode(f.read())
self.env['fusion.pdf.template.preview'].create({
'template_id': self.id,
'page': idx + 1,
'image': image_data,
'image_filename': f'page_{idx + 1}.png',
})
_logger.info("Generated %d preview images for template %s", len(png_files), self.name)
except subprocess.TimeoutExpired:
raise UserError(_('PDF conversion timed out. Please upload preview images manually.'))
except FileNotFoundError:
raise UserError(_(
'poppler-utils (pdftoppm) is not installed on the server. '
'Please upload preview images manually in the Page Previews tab.'
))
@api.depends('field_ids')
def _compute_field_count(self):
for rec in self:
rec.field_count = len(rec.field_ids)
def action_activate(self):
"""Set template to active."""
self.ensure_one()
if not self.pdf_file:
raise UserError(_('Please upload a PDF file before activating.'))
self.state = 'active'
def action_archive(self):
"""Archive the template."""
self.ensure_one()
self.state = 'archived'
def action_reset_draft(self):
"""Reset to draft."""
self.ensure_one()
self.state = 'draft'
def action_open_field_editor(self):
"""Open the visual field position editor."""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': f'/fusion/pdf-editor/{self.id}',
'target': 'new',
}
def generate_filled_pdf(self, context_data, signatures=None):
"""Generate a filled PDF using this template and the provided data.
Args:
context_data: flat dict of {field_key: value}
signatures: dict of {field_key: binary_png} for signature fields
Returns:
bytes of the filled PDF
"""
self.ensure_one()
if not self.pdf_file:
raise UserError(_('Template has no PDF file.'))
if self.state != 'active':
_logger.warning("Generating PDF from non-active template %s", self.name)
from ..utils.pdf_filler import PDFTemplateFiller
template_bytes = base64.b64decode(self.pdf_file)
# Build fields_by_page dict
fields_by_page = {}
for field in self.field_ids.filtered(lambda f: f.is_active):
page = field.page
if page not in fields_by_page:
fields_by_page[page] = []
fields_by_page[page].append({
'field_name': field.name,
'field_key': field.field_key or field.name,
'pos_x': field.pos_x,
'pos_y': field.pos_y,
'width': field.width,
'height': field.height,
'field_type': field.field_type,
'font_size': field.font_size,
'font_name': field.font_name or 'Helvetica',
'text_align': field.text_align or 'left',
})
return PDFTemplateFiller.fill_template(
template_bytes, fields_by_page, context_data, signatures
)
class FusionPdfTemplatePreview(models.Model):
_name = 'fusion.pdf.template.preview'
_description = 'PDF Template Page Preview'
_order = 'page'
template_id = fields.Many2one(
'fusion.pdf.template', string='Template',
required=True, ondelete='cascade', index=True,
)
page = fields.Integer(string='Page Number', required=True, default=1)
image = fields.Binary(string='Page Image (PNG)', attachment=True)
image_filename = fields.Char(string='Image Filename')
class FusionPdfTemplateField(models.Model):
_name = 'fusion.pdf.template.field'
_description = 'PDF Template Field'
_order = 'page, sequence'
template_id = fields.Many2one(
'fusion.pdf.template', string='Template',
required=True, ondelete='cascade', index=True,
)
name = fields.Char(
string='Field Name', required=True,
help='Internal identifier, e.g. client_last_name',
)
label = fields.Char(
string='Display Label',
help='Human-readable label shown in the editor, e.g. "Last Name"',
)
sequence = fields.Integer(string='Sequence', default=10)
page = fields.Integer(string='Page', default=1, required=True)
# Percentage-based positioning (0.0 to 1.0) -- same as sign.item
pos_x = fields.Float(
string='Position X', digits=(4, 3),
help='Horizontal position as ratio (0.0 = left edge, 1.0 = right edge)',
)
pos_y = fields.Float(
string='Position Y', digits=(4, 3),
help='Vertical position as ratio (0.0 = top edge, 1.0 = bottom edge)',
)
width = fields.Float(
string='Width', digits=(4, 3), default=0.150,
help='Width as ratio of page width',
)
height = fields.Float(
string='Height', digits=(4, 3), default=0.015,
help='Height as ratio of page height',
)
# Rendering settings
field_type = fields.Selection([
('text', 'Text'),
('checkbox', 'Checkbox'),
('signature', 'Signature Image'),
('date', 'Date'),
], string='Field Type', default='text', required=True)
font_size = fields.Float(string='Font Size', default=10.0)
font_name = fields.Selection([
('Helvetica', 'Helvetica'),
('Courier', 'Courier'),
('Times-Roman', 'Times Roman'),
], string='Font', default='Helvetica')
text_align = fields.Selection([
('left', 'Left'),
('center', 'Center'),
('right', 'Right'),
], string='Text Alignment', default='left')
# Data mapping
field_key = fields.Char(
string='Data Key',
help='Key to look up in the data context dict.\n'
'Examples: client_last_name, client_health_card, consent_date, signature_page_11\n'
'The generating code passes a flat dict of all available data.',
)
default_value = fields.Char(
string='Default Value',
help='Fallback value if field_key returns empty',
)
is_active = fields.Boolean(string='Active', default=True)

View File

@@ -0,0 +1,764 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from markupsafe import Markup, escape
import logging
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
# Portal Role Flags
is_authorizer = fields.Boolean(
string='Is Authorizer',
default=False,
help='Check if this partner is an Authorizer (OT/Therapist) who can access the Authorizer Portal',
)
is_sales_rep_portal = fields.Boolean(
string='Is Sales Rep (Portal)',
default=False,
help='Check if this partner is a Sales Rep who can access the Sales Rep Portal',
)
is_client_portal = fields.Boolean(
string='Is Client (Portal)',
default=False,
help='Check if this partner can access the Funding Claims Portal to view their claims',
)
is_technician_portal = fields.Boolean(
string='Is Technician (Portal)',
default=False,
help='Check if this partner is a Field Technician who can access the Technician Portal for deliveries',
)
# Computed field for assigned deliveries (for technicians)
assigned_delivery_count = fields.Integer(
string='Assigned Deliveries',
compute='_compute_assigned_delivery_count',
help='Number of sale orders assigned to this partner as delivery technician',
)
# Geocoding coordinates (for travel time calculations)
x_fc_latitude = fields.Float(
string='Latitude',
digits=(10, 7),
help='GPS latitude of the partner address (auto-geocoded)',
)
x_fc_longitude = fields.Float(
string='Longitude',
digits=(10, 7),
help='GPS longitude of the partner address (auto-geocoded)',
)
# Link to portal user account
authorizer_portal_user_id = fields.Many2one(
'res.users',
string='Portal User Account',
help='The portal user account linked to this authorizer/sales rep',
copy=False,
)
# Portal access status tracking
portal_access_status = fields.Selection(
selection=[
('no_access', 'No Access'),
('invited', 'Invited'),
('active', 'Active'),
],
string='Portal Status',
compute='_compute_portal_access_status',
store=True,
help='Tracks portal access: No Access = no portal user, Invited = user created but never logged in, Active = user has logged in',
)
# Computed counts
assigned_case_count = fields.Integer(
string='Assigned Cases',
compute='_compute_assigned_case_count',
help='Number of sale orders assigned to this partner as authorizer',
)
assessment_count = fields.Integer(
string='Assessments',
compute='_compute_assessment_count',
help='Number of assessments linked to this partner',
)
@api.depends('authorizer_portal_user_id', 'authorizer_portal_user_id.login_date')
def _compute_portal_access_status(self):
"""Compute portal access status based on user account and login history."""
for partner in self:
if not partner.authorizer_portal_user_id:
partner.portal_access_status = 'no_access'
elif partner.authorizer_portal_user_id.login_date:
partner.portal_access_status = 'active'
else:
partner.portal_access_status = 'invited'
@api.depends('is_authorizer')
def _compute_assigned_case_count(self):
"""Count sale orders where this partner is the authorizer"""
SaleOrder = self.env['sale.order'].sudo()
for partner in self:
if partner.is_authorizer:
# Use x_fc_authorizer_id field from fusion_claims
domain = [('x_fc_authorizer_id', '=', partner.id)]
partner.assigned_case_count = SaleOrder.search_count(domain)
else:
partner.assigned_case_count = 0
@api.depends('is_authorizer', 'is_sales_rep_portal')
def _compute_assessment_count(self):
"""Count assessments where this partner is involved"""
Assessment = self.env['fusion.assessment'].sudo()
for partner in self:
count = 0
if partner.is_authorizer:
count += Assessment.search_count([('authorizer_id', '=', partner.id)])
if partner.is_sales_rep_portal and partner.authorizer_portal_user_id:
count += Assessment.search_count([('sales_rep_id', '=', partner.authorizer_portal_user_id.id)])
partner.assessment_count = count
@api.depends('is_technician_portal')
def _compute_assigned_delivery_count(self):
"""Count sale orders assigned to this partner as delivery technician"""
SaleOrder = self.env['sale.order'].sudo()
for partner in self:
if partner.is_technician_portal and partner.authorizer_portal_user_id:
# Technicians are linked via user_id in x_fc_delivery_technician_ids
domain = [('x_fc_delivery_technician_ids', 'in', [partner.authorizer_portal_user_id.id])]
partner.assigned_delivery_count = SaleOrder.search_count(domain)
else:
partner.assigned_delivery_count = 0
def _assign_portal_role_groups(self, portal_user):
"""Assign role-specific portal groups to a portal user based on contact checkboxes."""
groups_to_add = []
if self.is_technician_portal:
g = self.env.ref('fusion_authorizer_portal.group_technician_portal', raise_if_not_found=False)
if g and g not in portal_user.group_ids:
groups_to_add.append((4, g.id))
if self.is_authorizer:
g = self.env.ref('fusion_authorizer_portal.group_authorizer_portal', raise_if_not_found=False)
if g and g not in portal_user.group_ids:
groups_to_add.append((4, g.id))
if self.is_sales_rep_portal:
g = self.env.ref('fusion_authorizer_portal.group_sales_rep_portal', raise_if_not_found=False)
if g and g not in portal_user.group_ids:
groups_to_add.append((4, g.id))
if groups_to_add:
portal_user.sudo().write({'group_ids': groups_to_add})
def _assign_internal_role_groups(self, internal_user):
"""Assign backend groups to an internal user based on contact checkboxes.
Also sets x_fc_is_field_staff so the user appears in technician/staff dropdowns.
Returns list of group names that were added."""
added = []
needs_field_staff = False
if self.is_technician_portal:
# Add Field Technician group
g = self.env.ref('fusion_claims.group_field_technician', raise_if_not_found=False)
if g and g not in internal_user.group_ids:
internal_user.sudo().write({'group_ids': [(4, g.id)]})
added.append('Field Technician')
needs_field_staff = True
if self.is_sales_rep_portal:
# Internal sales reps don't need a portal group but should show in staff dropdowns
added.append('Sales Rep (internal)')
needs_field_staff = True
if self.is_authorizer:
# Internal authorizers already have full backend access
added.append('Authorizer (internal)')
# Mark as field staff so they appear in technician/delivery dropdowns
if needs_field_staff and hasattr(internal_user, 'x_fc_is_field_staff'):
if not internal_user.x_fc_is_field_staff:
internal_user.sudo().write({'x_fc_is_field_staff': True})
added.append('Field Staff')
return added
def action_grant_portal_access(self):
"""Grant portal access to this partner, or update permissions for existing users."""
self.ensure_one()
if not self.email:
raise UserError(_('Please set an email address before granting portal access.'))
email_normalized = self.email.strip().lower()
# ── Step 1: Find existing user ──
# Search by partner_id first (direct link)
existing_user = self.env['res.users'].sudo().search([
('partner_id', '=', self.id),
], limit=1)
# If not found by partner, search by email (handles internal users
# whose auto-created partner is different from this contact)
if not existing_user:
existing_user = self.env['res.users'].sudo().search([
'|',
('login', '=ilike', email_normalized),
('email', '=ilike', email_normalized),
], limit=1)
# ── Step 2: Handle existing user ──
if existing_user:
from datetime import datetime
self.authorizer_portal_user_id = existing_user
if not existing_user.share:
# ── INTERNAL user: assign backend groups, do NOT add portal ──
groups_added = self._assign_internal_role_groups(existing_user)
groups_text = ', '.join(groups_added) if groups_added else 'No new groups needed'
chatter_msg = Markup(
'<div style="border: 1px solid #6f42c1; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #6f42c1; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-user-circle"></i> Internal User &mdash; Permissions Updated'
'</div>'
'<div style="padding: 12px; background: #f8f5ff;">'
'<table style="font-size: 13px; width: 100%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User:</td><td>{escape(existing_user.name)} (ID: {existing_user.id})</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Login:</td><td>{escape(existing_user.login)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Type:</td><td>Internal (backend) user</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Groups added:</td><td>{escape(groups_text)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Updated by:</td><td>{escape(self.env.user.name)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Updated at:</td><td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
notify_msg = _('Internal user detected. Backend permissions updated: %s') % groups_text
else:
# ── Existing PORTAL user: ensure role groups are set ──
portal_group = self.env.ref('base.group_portal', raise_if_not_found=False)
if portal_group and portal_group not in existing_user.group_ids:
existing_user.sudo().write({'group_ids': [(4, portal_group.id)]})
self._assign_portal_role_groups(existing_user)
chatter_msg = Markup(
'<div style="border: 1px solid #17a2b8; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #17a2b8; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-check-circle"></i> Portal Access &mdash; Roles Updated'
'</div>'
'<div style="padding: 12px; background: #f0f9ff;">'
'<table style="font-size: 13px; width: 100%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td><td>Portal user exists &mdash; roles updated</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User:</td><td>{escape(existing_user.name)} (ID: {existing_user.id})</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Login:</td><td>{escape(existing_user.login)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Checked by:</td><td>{escape(self.env.user.name)}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Checked at:</td><td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
notify_msg = _('Portal user already exists — role groups updated (User ID: %s).') % existing_user.id
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Access Updated'),
'message': notify_msg,
'type': 'info',
'sticky': False,
}
}
# No existing user found - create portal user directly
portal_group = self.env.ref('base.group_portal', raise_if_not_found=False)
if not portal_group:
raise UserError(_('Portal group not found. Please contact administrator.'))
try:
# Create user without groups first (Odoo 17+ compatibility)
portal_user = self.env['res.users'].sudo().with_context(no_reset_password=True, knowledge_skip_onboarding_article=True).create({
'name': self.name,
'login': email_normalized,
'email': self.email,
'partner_id': self.id,
'active': True,
})
# Add portal group after creation
portal_user.sudo().write({
'group_ids': [(6, 0, [portal_group.id])],
})
# Assign role-specific portal groups based on contact checkboxes
self._assign_portal_role_groups(portal_user)
self.authorizer_portal_user_id = portal_user
# Create welcome Knowledge article for the user
self._create_welcome_article(portal_user)
# Send professional portal invitation email
email_sent = False
try:
email_sent = self._send_portal_invitation_email(portal_user)
except Exception as mail_error:
_logger.warning(f"Could not send portal invitation email: {mail_error}")
# Post message in chatter
sent_by = self.env.user.name
from datetime import datetime
sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if email_sent:
status_text = '<span style="color: green;">Invitation email sent successfully</span>'
border_color = '#28a745'
header_bg = '#28a745'
body_bg = '#f0fff0'
else:
status_text = '<span style="color: orange;">User created but email could not be sent</span>'
border_color = '#fd7e14'
header_bg = '#fd7e14'
body_bg = '#fff8f0'
chatter_msg = Markup(
f'<div style="border: 1px solid {border_color}; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
f'<div style="background: {header_bg}; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-envelope"></i> Portal Access Granted'
'</div>'
f'<div style="padding: 12px; background: {body_bg};">'
'<table style="font-size: 13px; width: 100%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User ID:</td><td>{portal_user.id}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td><td>{status_text}</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Portal Access Granted'),
'message': _('Portal user created for %s. A password reset email has been sent.') % self.email,
'type': 'success',
'sticky': False,
}
}
except Exception as e:
_logger.error(f"Failed to create portal user: {e}")
raise UserError(_('Failed to create portal user: %s') % str(e))
def _create_welcome_article(self, portal_user):
"""Create a role-specific welcome Knowledge article for the new portal user.
Determines the role from partner flags and renders the matching template.
The article is private to the user and set as a favorite.
"""
self.ensure_one()
# Check if Knowledge module is installed
if 'knowledge.article' not in self.env:
_logger.info("Knowledge module not installed, skipping welcome article")
return
# Determine role and template
if self.is_technician_portal:
template_xmlid = 'fusion_authorizer_portal.welcome_article_technician'
icon = '🔧'
title = f"Welcome {self.name} - Technician Portal"
elif self.is_authorizer:
template_xmlid = 'fusion_authorizer_portal.welcome_article_authorizer'
icon = '📋'
title = f"Welcome {self.name} - Authorizer Portal"
elif self.is_sales_rep_portal:
template_xmlid = 'fusion_authorizer_portal.welcome_article_sales_rep'
icon = '💼'
title = f"Welcome {self.name} - Sales Portal"
elif self.is_client_portal:
template_xmlid = 'fusion_authorizer_portal.welcome_article_client'
icon = '👤'
title = f"Welcome {self.name}"
else:
template_xmlid = 'fusion_authorizer_portal.welcome_article_client'
icon = '👋'
title = f"Welcome {self.name}"
company = self.env.company
render_ctx = {
'user_name': self.name or 'Valued Partner',
'company_name': company.name or 'Our Company',
'company_email': company.email or '',
'company_phone': company.phone or '',
}
try:
body = self.env['ir.qweb']._render(
template_xmlid,
render_ctx,
minimal_qcontext=True,
raise_if_not_found=False,
)
if not body:
_logger.warning(f"Welcome article template not found: {template_xmlid}")
return
article = self.env['knowledge.article'].sudo().create({
'name': title,
'icon': icon,
'body': body,
'internal_permission': 'none',
'is_article_visible_by_everyone': False,
'article_member_ids': [(0, 0, {
'partner_id': self.id,
'permission': 'write',
})],
'favorite_ids': [(0, 0, {
'sequence': 0,
'user_id': portal_user.id,
})],
})
_logger.info(f"Created welcome article '{title}' (ID: {article.id}) for {self.name}")
except Exception as e:
_logger.warning(f"Failed to create welcome article for {self.name}: {e}")
def _send_portal_invitation_email(self, portal_user, is_resend=False):
"""Send a professional portal invitation email to the partner.
Generates a signup URL and sends a branded invitation email
instead of the generic Odoo password reset email.
Returns True if email was sent successfully, False otherwise.
"""
self.ensure_one()
# Generate signup token and build URL
partner = portal_user.sudo().partner_id
# Set signup type to 'signup' - this auto-logs in after password is set
partner.signup_prepare(signup_type='signup')
# Use Odoo's built-in URL generation with signup_email context
# so the email is pre-filled and user just sets password
signup_urls = partner.with_context(
signup_valid=True,
create_user=True,
)._get_signup_url_for_action()
signup_url = signup_urls.get(partner.id)
if not signup_url:
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
signup_url = f"{base_url}/web/reset_password"
_logger.warning(f"Could not generate signup URL for {self.email}, using generic reset page")
company = self.env.company
company_name = company.name or 'Our Company'
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
partner_name = self.name or 'Valued Partner'
subject = f"You're Invited to the {company_name} Portal" if not is_resend else f"Portal Access Reminder - {company_name}"
invite_text = 'We are pleased to invite you' if not is_resend else 'This is a reminder that you have been invited'
body_html = (
f'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
f'max-width:600px;margin:0 auto;color:#2d3748;">'
f'<div style="height:4px;background-color:#2B6CB0;"></div>'
f'<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">'
f'<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;'
f'text-transform:uppercase;margin:0 0 24px 0;">{company_name}</p>'
f'<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Portal Invitation</h2>'
f'<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">'
f'Dear {partner_name}, {invite_text} to access the <strong>{company_name} Portal</strong>.</p>'
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:0 0 20px 0;">'
f'With the portal you can:</p>'
f'<ul style="color:#2d3748;font-size:14px;line-height:1.8;margin:0 0 24px 0;padding-left:20px;">'
f'<li>View and manage your assigned cases</li>'
f'<li>Complete assessments online</li>'
f'<li>Track application status and progress</li>'
f'<li>Access important documents</li></ul>'
f'<p style="text-align:center;margin:28px 0;">'
f'<a href="{signup_url}" style="display:inline-block;background:#2B6CB0;color:#ffffff;'
f'padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">'
f'Accept Invitation &amp; Set Password</a></p>'
f'<p style="font-size:12px;color:#718096;text-align:center;margin:0 0 20px 0;">'
f'If the button does not work, copy this link: '
f'<a href="{signup_url}" style="color:#2B6CB0;word-break:break-all;">{signup_url}</a></p>'
f'<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">'
f'<p style="margin:0;font-size:14px;color:#2d3748;">'
f'After setting your password, access the portal anytime at: '
f'<a href="{base_url}/my" style="color:#2B6CB0;">{base_url}/my</a></p></div>'
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
f'Best regards,<br/><strong>{company_name} Team</strong></p>'
f'</div>'
f'<div style="padding:16px 28px;text-align:center;">'
f'<p style="color:#a0aec0;font-size:11px;margin:0;">'
f'This is an automated message from {company_name}.</p></div></div>'
)
mail_values = {
'subject': subject,
'body_html': body_html,
'email_to': self.email,
'email_from': company.email or self.env.user.email or 'noreply@example.com',
'auto_delete': True,
}
try:
mail = self.env['mail.mail'].sudo().create(mail_values)
mail.send()
_logger.info(f"Portal invitation email sent to {self.email}")
return True
except Exception as e:
_logger.error(f"Failed to send portal invitation email to {self.email}: {e}")
return False
def action_resend_portal_invitation(self):
"""Resend portal invitation email to an existing portal user."""
self.ensure_one()
if not self.authorizer_portal_user_id:
raise UserError(_('No portal user found for this contact. Use "Send Portal Invitation" instead.'))
portal_user = self.authorizer_portal_user_id
# Send professional portal invitation email
email_sent = False
try:
email_sent = self._send_portal_invitation_email(portal_user, is_resend=True)
except Exception as mail_error:
_logger.warning(f"Could not send portal invitation email: {mail_error}")
# Post in chatter
from datetime import datetime
sent_by = self.env.user.name
sent_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if email_sent:
chatter_msg = Markup(
'<div style="border: 1px solid #17a2b8; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #17a2b8; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-refresh"></i> Portal Invitation Resent'
'</div>'
'<div style="padding: 12px; background: #f0f9ff;">'
'<table style="font-size: 13px; width: 100%%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">User ID:</td><td>{portal_user.id}</td></tr>'
'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td>'
'<td style="color: green;">Invitation email resent successfully</td></tr>'
'</table>'
'</div>'
'</div>'
)
else:
chatter_msg = Markup(
'<div style="border: 1px solid #fd7e14; border-radius: 4px; overflow: hidden; margin: 8px 0;">'
'<div style="background: #fd7e14; color: white; padding: 10px 12px; font-weight: 600;">'
'<i class="fa fa-refresh"></i> Portal Invitation Resend Attempted'
'</div>'
'<div style="padding: 12px; background: #fff8f0;">'
'<table style="font-size: 13px; width: 100%%;">'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Email:</td><td>{self.email}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent by:</td><td>{sent_by}</td></tr>'
f'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Sent at:</td><td>{sent_at}</td></tr>'
'<tr><td style="padding: 4px 12px 4px 0; font-weight: 500;">Status:</td>'
'<td style="color: orange;">Email could not be sent - check mail configuration</td></tr>'
'</table>'
'</div>'
'</div>'
)
self.message_post(body=chatter_msg, message_type='notification', subtype_xmlid='mail.mt_note')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Portal Invitation Resent') if email_sent else _('Email Failed'),
'message': _('Portal invitation resent to %s.') % self.email if email_sent else _('Could not send email. Check mail configuration.'),
'type': 'success' if email_sent else 'warning',
'sticky': False,
}
}
def action_view_assigned_cases(self):
"""Open the list of assigned sale orders"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Assigned Cases'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_authorizer_id', '=', self.id)],
'context': {'default_x_fc_authorizer_id': self.id},
}
def action_view_assessments(self):
"""Open the list of assessments for this partner"""
self.ensure_one()
domain = []
if self.is_authorizer:
domain = [('authorizer_id', '=', self.id)]
elif self.is_sales_rep_portal and self.authorizer_portal_user_id:
domain = [('sales_rep_id', '=', self.authorizer_portal_user_id.id)]
return {
'type': 'ir.actions.act_window',
'name': _('Assessments'),
'res_model': 'fusion.assessment',
'view_mode': 'list,form',
'domain': domain,
}
# ==================== BATCH ACTIONS ====================
def action_mark_as_authorizer(self):
"""Batch action to mark selected contacts as authorizers"""
self.write({'is_authorizer': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Authorizers Updated'),
'message': _('%d contact(s) marked as authorizer.') % len(self),
'type': 'success',
'sticky': False,
}
}
def action_batch_send_portal_invitation(self):
"""Batch action to send portal invitations to selected authorizers"""
sent_count = 0
skipped_no_email = 0
skipped_not_authorizer = 0
skipped_has_access = 0
errors = []
for partner in self:
if not partner.is_authorizer:
skipped_not_authorizer += 1
continue
if not partner.email:
skipped_no_email += 1
continue
if partner.authorizer_portal_user_id:
skipped_has_access += 1
continue
try:
partner.action_grant_portal_access()
sent_count += 1
except Exception as e:
errors.append(f"{partner.name}: {str(e)}")
# Build result message
messages = []
if sent_count:
messages.append(_('%d invitation(s) sent successfully.') % sent_count)
if skipped_not_authorizer:
messages.append(_('%d skipped (not marked as authorizer).') % skipped_not_authorizer)
if skipped_no_email:
messages.append(_('%d skipped (no email).') % skipped_no_email)
if skipped_has_access:
messages.append(_('%d skipped (already has portal access).') % skipped_has_access)
if errors:
messages.append(_('%d error(s) occurred.') % len(errors))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Portal Invitations'),
'message': ' '.join(messages),
'type': 'success' if sent_count and not errors else 'warning' if not errors else 'danger',
'sticky': True if errors else False,
}
}
def action_mark_and_send_invitation(self):
"""Combined action: mark as authorizer and send invitation"""
self.action_mark_as_authorizer()
return self.action_batch_send_portal_invitation()
def action_view_assigned_deliveries(self):
"""Open the list of assigned deliveries for technician"""
self.ensure_one()
if not self.authorizer_portal_user_id:
raise UserError(_('This partner does not have a portal user account.'))
return {
'type': 'ir.actions.act_window',
'name': _('Assigned Deliveries'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])],
}
def action_mark_as_technician(self):
"""Batch action to mark selected contacts as technicians"""
self.write({'is_technician_portal': True})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Technicians Updated'),
'message': _('%d contact(s) marked as technician.') % len(self),
'type': 'success',
'sticky': False,
}
}
# ------------------------------------------------------------------
# GEOCODING
# ------------------------------------------------------------------
def _geocode_address(self):
"""Geocode partner address using Google Geocoding API and cache lat/lng."""
import requests as http_requests
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', ''
)
if not api_key:
return
for partner in self:
parts = [partner.street, partner.city,
partner.state_id.name if partner.state_id else '',
partner.zip]
address = ', '.join([p for p in parts if p])
if not address:
continue
try:
resp = http_requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address, 'key': api_key, 'region': 'ca'},
timeout=10,
)
data = resp.json()
if data.get('status') == 'OK' and data.get('results'):
loc = data['results'][0]['geometry']['location']
partner.write({
'x_fc_latitude': loc['lat'],
'x_fc_longitude': loc['lng'],
})
except Exception as e:
_logger.warning(f"Geocoding failed for partner {partner.id}: {e}")
def write(self, vals):
"""Override write to auto-geocode when address changes."""
res = super().write(vals)
address_fields = {'street', 'city', 'state_id', 'zip', 'country_id'}
if address_fields & set(vals.keys()):
# Check if distance matrix is enabled before geocoding
enabled = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_distance_matrix_enabled', False
)
if enabled:
self._geocode_address()
return res

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
from odoo import api, models, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class PortalWizardUser(models.TransientModel):
"""Override standard portal wizard to handle internal users with Fusion roles."""
_inherit = 'portal.wizard.user'
def action_grant_access(self):
"""Override: Handle Fusion portal roles when granting portal access.
- Internal users with Fusion roles: assign backend groups, skip portal.
- Portal users with Fusion roles: standard flow + assign role groups.
"""
self.ensure_one()
partner = self.partner_id
# Check if the partner has any Fusion portal flags
has_fusion_role = getattr(partner, 'is_technician_portal', False) or \
getattr(partner, 'is_authorizer', False) or \
getattr(partner, 'is_sales_rep_portal', False)
# Find the linked user
user = self.user_id
if user and user._is_internal() and has_fusion_role:
# Internal user with Fusion roles -- assign backend groups, no portal
partner._assign_internal_role_groups(user)
partner.authorizer_portal_user_id = user
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Internal User Updated'),
'message': _('%s is an internal user. Backend permissions updated (no portal access needed).') % partner.name,
'type': 'info',
'sticky': True,
}
}
# Standard Odoo portal flow (creates user, sends email, etc.)
result = super().action_grant_access()
# After standard flow, assign Fusion portal role groups
if has_fusion_role:
portal_user = self.user_id
if not portal_user:
# Fallback: find the user that was just created
portal_user = self.env['res.users'].sudo().search([
('partner_id', '=', partner.id),
('share', '=', True),
('active', '=', True),
], limit=1)
if portal_user:
partner._assign_portal_role_groups(portal_user)
if not partner.authorizer_portal_user_id:
partner.authorizer_portal_user_id = portal_user
_logger.info("Assigned Fusion portal role groups to user %s (partner: %s)",
portal_user.login, partner.name)
return result
class ResUsers(models.Model):
_inherit = 'res.users'
def _generate_tutorial_articles(self):
"""Override to create custom welcome articles for internal staff
instead of the default Odoo Knowledge onboarding article.
"""
if 'knowledge.article' not in self.env:
return super()._generate_tutorial_articles()
for user in self:
company = user.company_id or self.env.company
render_ctx = {
'user_name': user.name or 'Team Member',
'company_name': company.name or 'Our Company',
'company_email': company.email or '',
'company_phone': company.phone or '',
}
try:
body = self.env['ir.qweb']._render(
'fusion_authorizer_portal.welcome_article_internal',
render_ctx,
minimal_qcontext=True,
raise_if_not_found=False,
)
if not body:
_logger.warning("Internal staff welcome template not found, using default")
return super()._generate_tutorial_articles()
self.env['knowledge.article'].sudo().create({
'name': f"Welcome {user.name} - {company.name}",
'icon': '🏢',
'body': body,
'internal_permission': 'none',
'is_article_visible_by_everyone': False,
'article_member_ids': [(0, 0, {
'partner_id': user.partner_id.id,
'permission': 'write',
})],
'favorite_ids': [(0, 0, {
'sequence': 0,
'user_id': user.id,
})],
})
_logger.info(f"Created custom welcome article for internal user {user.name}")
except Exception as e:
_logger.warning(f"Failed to create custom welcome article for {user.name}: {e}")
# Fall back to default
super(ResUsers, user)._generate_tutorial_articles()

View File

@@ -0,0 +1,250 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
import logging
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
# Comments from portal users
portal_comment_ids = fields.One2many(
'fusion.authorizer.comment',
'sale_order_id',
string='Portal Comments',
)
portal_comment_count = fields.Integer(
string='Comment Count',
compute='_compute_portal_comment_count',
)
# Documents uploaded via portal
portal_document_ids = fields.One2many(
'fusion.adp.document',
'sale_order_id',
string='Portal Documents',
)
portal_document_count = fields.Integer(
string='Document Count',
compute='_compute_portal_document_count',
)
# Link to assessment
assessment_id = fields.Many2one(
'fusion.assessment',
string='Source Assessment',
readonly=True,
help='The assessment that created this sale order',
)
# Authorizer helper field (consolidates multiple possible fields)
portal_authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer (Portal)',
compute='_compute_portal_authorizer_id',
store=True,
help='Consolidated authorizer field for portal access',
)
@api.depends('portal_comment_ids')
def _compute_portal_comment_count(self):
for order in self:
order.portal_comment_count = len(order.portal_comment_ids)
@api.depends('portal_document_ids')
def _compute_portal_document_count(self):
for order in self:
order.portal_document_count = len(order.portal_document_ids)
@api.depends('x_fc_authorizer_id')
def _compute_portal_authorizer_id(self):
"""Get authorizer from x_fc_authorizer_id field"""
for order in self:
order.portal_authorizer_id = order.x_fc_authorizer_id
def write(self, vals):
"""Override write to send notification when authorizer is assigned."""
old_authorizers = {
order.id: order.x_fc_authorizer_id.id if order.x_fc_authorizer_id else False
for order in self
}
result = super().write(vals)
# Check for authorizer changes
if 'x_fc_authorizer_id' in vals:
for order in self:
old_auth = old_authorizers.get(order.id)
new_auth = vals.get('x_fc_authorizer_id')
if new_auth and new_auth != old_auth:
order._send_authorizer_assignment_notification()
# NOTE: Generic status change notifications removed.
# Each status transition already sends its own detailed email
# from fusion_claims (approval, denial, submission, billed, etc.)
# A generic "status changed" email on top was redundant and lacked detail.
return result
def action_message_authorizer(self):
"""Open composer to send message to authorizer only"""
self.ensure_one()
if not self.x_fc_authorizer_id:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'name': 'Message Authorizer',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'target': 'new',
'context': {
'default_model': 'sale.order',
'default_res_ids': [self.id],
'default_partner_ids': [self.x_fc_authorizer_id.id],
'default_composition_mode': 'comment',
'default_subtype_xmlid': 'mail.mt_note',
},
}
def _send_authorizer_assignment_notification(self):
"""Send email when an authorizer is assigned to the order"""
self.ensure_one()
if not self.x_fc_authorizer_id or not self.x_fc_authorizer_id.email:
return
try:
template = self.env.ref('fusion_authorizer_portal.mail_template_case_assigned', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=False)
_logger.info(f"Sent case assignment notification to {self.x_fc_authorizer_id.email} for {self.name}")
except Exception as e:
_logger.error(f"Failed to send authorizer assignment notification: {e}")
# _send_status_change_notification removed -- redundant.
# Each workflow transition in fusion_claims sends its own detailed email.
def action_view_portal_comments(self):
"""View portal comments"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Portal Comments'),
'res_model': 'fusion.authorizer.comment',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
def action_view_portal_documents(self):
"""View portal documents"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Portal Documents'),
'res_model': 'fusion.adp.document',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
def get_portal_display_data(self):
"""Get data for portal display, excluding sensitive information"""
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'date_order': self.date_order,
'state': self.state,
'state_display': dict(self._fields['state'].selection).get(self.state, self.state),
'partner_name': self.partner_id.name if self.partner_id else '',
'partner_address': self._get_partner_address_display(),
'client_reference_1': self.x_fc_client_ref_1 or '',
'client_reference_2': self.x_fc_client_ref_2 or '',
'claim_number': self.x_fc_claim_number or '',
'authorizer_name': self.x_fc_authorizer_id.name if self.x_fc_authorizer_id else '',
'sales_rep_name': self.user_id.name if self.user_id else '',
'product_lines': self._get_product_lines_for_portal(),
'comment_count': self.portal_comment_count,
'document_count': self.portal_document_count,
}
def _get_partner_address_display(self):
"""Get formatted partner address for display"""
if not self.partner_id:
return ''
parts = []
if self.partner_id.street:
parts.append(self.partner_id.street)
if self.partner_id.city:
city_part = self.partner_id.city
if self.partner_id.state_id:
city_part += f", {self.partner_id.state_id.name}"
if self.partner_id.zip:
city_part += f" {self.partner_id.zip}"
parts.append(city_part)
return ', '.join(parts)
def _get_product_lines_for_portal(self):
"""Get product lines for portal display (excluding costs)"""
lines = []
for line in self.order_line:
lines.append({
'id': line.id,
'product_name': line.product_id.name if line.product_id else line.name,
'quantity': line.product_uom_qty,
'uom': line.product_uom_id.name if line.product_uom_id else '',
'adp_code': line.x_fc_adp_device_code or '' if hasattr(line, 'x_fc_adp_device_code') else '',
'device_type': '',
'serial_number': line.x_fc_serial_number or '' if hasattr(line, 'x_fc_serial_number') else '',
})
return lines
@api.model
def get_authorizer_portal_cases(self, partner_id, search_query=None, limit=100, offset=0):
"""Get cases for authorizer portal with optional search"""
domain = [('x_fc_authorizer_id', '=', partner_id)]
# Add search if provided
if search_query:
search_domain = self._build_search_domain(search_query)
domain = ['&'] + domain + search_domain
orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc')
return orders
@api.model
def get_sales_rep_portal_cases(self, user_id, search_query=None, limit=100, offset=0):
"""Get cases for sales rep portal with optional search"""
domain = [('user_id', '=', user_id)]
# Add search if provided
if search_query:
search_domain = self._build_search_domain(search_query)
domain = domain + search_domain
orders = self.sudo().search(domain, limit=limit, offset=offset, order='date_order desc, id desc')
return orders
def _build_search_domain(self, query):
"""Build search domain for portal search"""
if not query or len(query) < 2:
return []
search_domain = [
'|', '|', '|', '|',
('partner_id.name', 'ilike', query),
('x_fc_claim_number', 'ilike', query),
('x_fc_client_ref_1', 'ilike', query),
('x_fc_client_ref_2', 'ilike', query),
]
return search_domain

View File

@@ -0,0 +1,12 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_authorizer_comment_user,fusion.authorizer.comment.user,model_fusion_authorizer_comment,base.group_user,1,1,1,1
access_fusion_authorizer_comment_portal,fusion.authorizer.comment.portal,model_fusion_authorizer_comment,base.group_portal,1,1,1,0
access_fusion_adp_document_user,fusion.adp.document.user,model_fusion_adp_document,base.group_user,1,1,1,1
access_fusion_adp_document_portal,fusion.adp.document.portal,model_fusion_adp_document,base.group_portal,1,0,1,0
access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,base.group_user,1,1,1,1
access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0
access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1
access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0
access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1
access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1
access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_authorizer_comment_user fusion.authorizer.comment.user model_fusion_authorizer_comment base.group_user 1 1 1 1
3 access_fusion_authorizer_comment_portal fusion.authorizer.comment.portal model_fusion_authorizer_comment base.group_portal 1 1 1 0
4 access_fusion_adp_document_user fusion.adp.document.user model_fusion_adp_document base.group_user 1 1 1 1
5 access_fusion_adp_document_portal fusion.adp.document.portal model_fusion_adp_document base.group_portal 1 0 1 0
6 access_fusion_assessment_user fusion.assessment.user model_fusion_assessment base.group_user 1 1 1 1
7 access_fusion_assessment_portal fusion.assessment.portal model_fusion_assessment base.group_portal 1 1 1 0
8 access_fusion_accessibility_assessment_user fusion.accessibility.assessment.user model_fusion_accessibility_assessment base.group_user 1 1 1 1
9 access_fusion_accessibility_assessment_portal fusion.accessibility.assessment.portal model_fusion_accessibility_assessment base.group_portal 1 1 1 0
10 access_fusion_pdf_template_user fusion.pdf.template.user model_fusion_pdf_template base.group_user 1 1 1 1
11 access_fusion_pdf_template_preview_user fusion.pdf.template.preview.user model_fusion_pdf_template_preview base.group_user 1 1 1 1
12 access_fusion_pdf_template_field_user fusion.pdf.template.field.user model_fusion_pdf_template_field base.group_user 1 1 1 1

View File

@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Portal Groups - grouped under Fusion Claims privilege -->
<record id="group_authorizer_portal" model="res.groups">
<field name="name">Authorizer Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="comment">Portal users who are Authorizers (OTs/Therapists)</field>
</record>
<record id="group_sales_rep_portal" model="res.groups">
<field name="name">Sales Rep Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="comment">Portal users who are Sales Representatives</field>
</record>
<record id="group_technician_portal" model="res.groups">
<field name="name">Technician Portal User</field>
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
<field name="comment">Portal users who are Field Technicians for deliveries</field>
</record>
<!-- Authorizer Comment Access Rules -->
<record id="rule_comment_authorizer_own" model="ir.rule">
<field name="name">Authorizer: Own Comments</field>
<field name="model_id" ref="model_fusion_authorizer_comment"/>
<field name="domain_force">[('author_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_comment_view_on_order" model="ir.rule">
<field name="name">Portal: View Comments on Assigned Orders</field>
<field name="model_id" ref="model_fusion_authorizer_comment"/>
<field name="domain_force">[
'|',
('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id),
('sale_order_id.user_id', '=', user.id),
('is_internal', '=', False)
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ADP Document Access Rules -->
<record id="rule_document_portal_read" model="ir.rule">
<field name="name">Portal: Read Documents on Assigned Orders</field>
<field name="model_id" ref="model_fusion_adp_document"/>
<field name="domain_force">[
'|',
('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id),
('sale_order_id.user_id', '=', user.id)
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_document_authorizer_create" model="ir.rule">
<field name="name">Authorizer: Create Documents on Assigned Orders</field>
<field name="model_id" ref="model_fusion_adp_document"/>
<field name="domain_force">[
('sale_order_id.x_fc_authorizer_id', '=', user.partner_id.id),
('document_type', '!=', 'submitted_final')
]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Assessment Access Rules -->
<record id="rule_assessment_authorizer" model="ir.rule">
<field name="name">Authorizer: Own Assessments</field>
<field name="model_id" ref="model_fusion_assessment"/>
<field name="domain_force">[('authorizer_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_assessment_sales_rep" model="ir.rule">
<field name="name">Sales Rep: Own Assessments</field>
<field name="model_id" ref="model_fusion_assessment"/>
<field name="domain_force">[('sales_rep_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Sale Order Access - Extend for Portal -->
<record id="rule_sale_order_authorizer_portal" model="ir.rule">
<field name="name">Authorizer Portal: Assigned Orders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="domain_force">[('x_fc_authorizer_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Technician Portal: Access orders assigned for delivery -->
<record id="rule_sale_order_technician_portal" model="ir.rule">
<field name="name">Technician Portal: Assigned Deliveries</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="domain_force">[('x_fc_delivery_technician_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Sales Rep Portal: Access own orders for POD -->
<record id="rule_sale_order_sales_rep_portal" model="ir.rule">
<field name="name">Sales Rep Portal: Own Orders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,864 @@
/* Fusion Authorizer Portal - Custom Styles */
/* Color Scheme: Dark Blue (#1a365d, #2c5282) with Green accents (#38a169) */
:root {
--portal-primary: #1a365d;
--portal-primary-light: #2c5282;
--portal-accent: #38a169;
--portal-accent-light: #48bb78;
--portal-dark: #1a202c;
--portal-gray: #718096;
--portal-light: #f7fafc;
}
/* Portal Header Styling - Only for Fusion Portal pages */
/* Removed global navbar styling to prevent affecting other portal pages */
/* Card Headers with Portal Theme */
.card-header.bg-dark {
background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important;
}
.card-header.bg-primary {
background: var(--portal-primary-light) !important;
}
.card-header.bg-success {
background: var(--portal-accent) !important;
}
/* Stat Cards */
.card.bg-primary {
background: linear-gradient(135deg, var(--portal-primary) 0%, var(--portal-primary-light) 100%) !important;
}
.card.bg-success {
background: linear-gradient(135deg, var(--portal-accent) 0%, var(--portal-accent-light) 100%) !important;
}
/* Table Styling */
.table-dark th {
background: var(--portal-primary) !important;
}
.table-success th {
background: var(--portal-accent) !important;
color: white !important;
}
.table-info th {
background: var(--portal-primary-light) !important;
color: white !important;
}
/* Badges */
.badge.bg-primary {
background: var(--portal-primary-light) !important;
}
.badge.bg-success {
background: var(--portal-accent) !important;
}
/* Buttons */
.btn-primary {
background: var(--portal-primary-light) !important;
border-color: var(--portal-primary-light) !important;
}
.btn-primary:hover {
background: var(--portal-primary) !important;
border-color: var(--portal-primary) !important;
}
.btn-success {
background: var(--portal-accent) !important;
border-color: var(--portal-accent) !important;
}
.btn-success:hover {
background: var(--portal-accent-light) !important;
border-color: var(--portal-accent-light) !important;
}
.btn-outline-primary {
color: var(--portal-primary-light) !important;
border-color: var(--portal-primary-light) !important;
}
.btn-outline-primary:hover {
background: var(--portal-primary-light) !important;
color: white !important;
}
/* Search Box Styling */
#portal-search-input {
border-radius: 25px 0 0 25px;
padding-left: 20px;
}
#portal-search-input:focus {
border-color: var(--portal-primary-light);
box-shadow: 0 0 0 0.2rem rgba(44, 82, 130, 0.25);
}
/* Case List Row Hover */
.table-hover tbody tr:hover {
background-color: rgba(44, 82, 130, 0.1);
}
/* Document Upload Area */
.document-upload-area {
border: 2px dashed var(--portal-gray);
border-radius: 10px;
padding: 20px;
text-align: center;
background: var(--portal-light);
transition: all 0.3s ease;
}
.document-upload-area:hover {
border-color: var(--portal-primary-light);
background: rgba(44, 82, 130, 0.05);
}
/* Comment Section */
.comment-item {
border-left: 4px solid var(--portal-primary-light);
padding-left: 15px;
margin-bottom: 15px;
}
.comment-item .comment-author {
font-weight: 600;
color: var(--portal-primary);
}
.comment-item .comment-date {
font-size: 0.85em;
color: var(--portal-gray);
}
/* Signature Pad */
.signature-pad-container {
border: 2px solid var(--portal-gray);
border-radius: 8px;
padding: 10px;
background: white;
touch-action: none;
}
.signature-pad-container canvas {
cursor: crosshair;
width: 100%;
height: 200px;
}
/* Progress Bar */
.progress {
border-radius: 15px;
overflow: hidden;
}
.progress-bar {
font-size: 0.75rem;
font-weight: 600;
}
/* Assessment Form Cards */
.assessment-section-card {
transition: all 0.3s ease;
}
.assessment-section-card:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
/* Status Badges */
.status-badge {
padding: 0.5em 1em;
border-radius: 20px;
font-weight: 500;
}
.status-draft {
background: #e2e8f0;
color: #4a5568;
}
.status-pending {
background: #faf089;
color: #744210;
}
.status-completed {
background: #c6f6d5;
color: #276749;
}
.status-cancelled {
background: #fed7d7;
color: #9b2c2c;
}
/* Quick Action Buttons */
.quick-action-btn {
min-width: 150px;
margin-bottom: 10px;
}
/* Loading Spinner */
.search-loading {
display: none;
position: absolute;
right: 50px;
top: 50%;
transform: translateY(-50%);
}
.search-loading.active {
display: block;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.card-header {
font-size: 0.9rem;
}
.btn-lg {
font-size: 1rem;
padding: 0.5rem 1rem;
}
.table-responsive {
font-size: 0.85rem;
}
.signature-pad-container canvas {
height: 150px;
}
}
/* Animation for Search Results */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-result-row {
animation: fadeIn 0.3s ease;
}
/* Highlight matching text */
.search-highlight {
background-color: #faf089;
padding: 0 2px;
border-radius: 2px;
}
/* ========================================
EXPRESS ASSESSMENT FORM STYLES
======================================== */
.assessment-express-form .form-label {
color: #333;
font-size: 0.95rem;
}
.assessment-express-form .form-label.fw-bold {
font-weight: 600 !important;
}
.assessment-express-form .form-control,
.assessment-express-form .form-select {
border-radius: 6px;
border-color: #dee2e6;
padding: 0.625rem 0.875rem;
}
.assessment-express-form .form-control:focus,
.assessment-express-form .form-select:focus {
border-color: #2e7aad;
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
}
.assessment-express-form .form-select-lg {
padding: 0.75rem 1rem;
font-size: 1.1rem;
}
/* Input Group with Inch suffix */
.assessment-express-form .input-group-text {
background-color: #f8f9fa;
border-color: #dee2e6;
color: #6c757d;
font-weight: 500;
}
/* Checkbox and Radio Styling */
.assessment-express-form .form-check {
padding-left: 1.75rem;
margin-bottom: 0.5rem;
}
.assessment-express-form .form-check-input {
width: 1.15rem;
height: 1.15rem;
margin-top: 0.15rem;
margin-left: -1.75rem;
}
.assessment-express-form .form-check-input:checked {
background-color: #2e7aad;
border-color: #2e7aad;
}
.assessment-express-form .form-check-label {
color: #333;
cursor: pointer;
}
/* Equipment Form Sections */
.assessment-express-form .equipment-form h2 {
color: #1a1a1a;
font-size: 1.5rem;
letter-spacing: 1px;
}
/* Card Styling */
.assessment-express-form .card {
border: none;
border-radius: 12px;
}
.assessment-express-form .card-header.bg-primary {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
border-radius: 12px 12px 0 0;
}
.assessment-express-form .card-body {
padding: 2rem;
}
.assessment-express-form .card-footer {
border-top: 1px solid #e9ecef;
padding: 1.25rem 2rem;
}
/* Button Styling */
.assessment-express-form .btn-primary {
background: #2e7aad !important;
border-color: #4361ee !important;
padding: 0.75rem 2rem;
font-weight: 600;
border-radius: 6px;
}
.assessment-express-form .btn-primary:hover {
background: #3451d1 !important;
border-color: #3451d1 !important;
}
.assessment-express-form .btn-success {
background: #10b981 !important;
border-color: #10b981 !important;
padding: 0.75rem 2rem;
font-weight: 600;
border-radius: 6px;
}
.assessment-express-form .btn-success:hover {
background: #059669 !important;
border-color: #059669 !important;
}
.assessment-express-form .btn-outline-secondary {
border-width: 2px;
font-weight: 500;
}
/* Progress Bar */
.assessment-express-form .progress {
height: 8px;
background-color: #e9ecef;
}
.assessment-express-form .progress-bar {
background: linear-gradient(90deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
}
/* Section Separators */
.assessment-express-form hr {
border-color: #e9ecef;
opacity: 1;
}
/* Required Field Indicator */
.assessment-express-form .text-danger {
color: #dc3545 !important;
}
/* Section Headers within form */
.assessment-express-form h5.fw-bold {
color: #374151;
border-bottom: 2px solid #2e7aad;
padding-bottom: 0.5rem;
display: inline-block;
}
/* New Assessment Card on Portal Home */
.portal-new-assessment-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-new-assessment-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3) !important;
}
.portal-new-assessment-card .card-body {
background: transparent !important;
}
.portal-new-assessment-card h5,
.portal-new-assessment-card small {
color: #fff !important;
}
.portal-new-assessment-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-new-assessment-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Authorizer Portal Card on Portal Home */
.portal-authorizer-card {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-authorizer-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(46, 122, 173, 0.3) !important;
}
.portal-authorizer-card .card-body {
background: transparent !important;
}
.portal-authorizer-card h5,
.portal-authorizer-card small {
color: #fff !important;
}
.portal-authorizer-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-authorizer-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.assessment-express-form .card-body {
padding: 1.25rem;
}
.assessment-express-form .d-flex.flex-wrap.gap-4 {
gap: 0.5rem !important;
}
.assessment-express-form .row {
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.assessment-express-form .col-md-4,
.assessment-express-form .col-md-6 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
/* ================================================================== */
/* AUTHORIZER DASHBOARD - MOBILE-FIRST REDESIGN */
/* ================================================================== */
.auth-dash {
background: #f8f9fb;
min-height: 80vh;
}
/* Content Area */
.auth-dash-content {
padding-top: 24px;
padding-bottom: 40px;
}
/* Welcome Header */
.auth-dash-header {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
border-radius: 16px;
margin-bottom: 24px;
overflow: hidden;
}
.auth-dash-header-inner {
padding: 28px 30px 24px;
}
.auth-dash-greeting {
color: #fff;
font-size: 24px;
font-weight: 700;
margin: 0 0 4px 0;
letter-spacing: -0.3px;
}
.auth-dash-subtitle {
color: rgba(255,255,255,0.85);
font-size: 14px;
margin: 0;
font-weight: 400;
}
/* ---- Action Tiles ---- */
.auth-dash-tiles {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 28px;
}
.auth-tile {
display: flex;
align-items: center;
background: #fff;
border-radius: 14px;
padding: 18px 20px;
text-decoration: none !important;
color: #333 !important;
border: 1px solid #e8ecf1;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
min-height: 72px;
}
.auth-tile:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
border-color: #d0d5dd;
}
.auth-tile:active {
transform: scale(0.98);
}
.auth-tile-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
color: #fff;
margin-right: 16px;
}
.auth-tile-cases .auth-tile-icon {
background: linear-gradient(135deg, #2e7aad, #1a6b9a);
}
.auth-tile-assessments .auth-tile-icon {
background: linear-gradient(135deg, #5ba848, #4a9e3f);
}
.auth-tile-new .auth-tile-icon {
background: linear-gradient(135deg, #3a8fb7, #2e7aad);
}
.auth-tile-info {
flex: 1;
min-width: 0;
}
.auth-tile-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
line-height: 1.3;
}
.auth-tile-desc {
font-size: 13px;
color: #8b95a5;
line-height: 1.3;
margin-top: 3px;
}
.auth-tile-badge .badge {
background: #eef1f7;
color: #3949ab;
font-size: 15px;
font-weight: 700;
padding: 5px 14px;
border-radius: 20px;
margin-right: 10px;
}
.auth-tile-arrow {
color: #c5ccd6;
font-size: 14px;
flex-shrink: 0;
}
/* ---- Sections ---- */
.auth-dash-section {
background: #fff;
border-radius: 14px;
overflow: hidden;
margin-bottom: 20px;
border: 1px solid #e8ecf1;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.auth-section-header {
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #444;
border-bottom: 1px solid #f0f2f5;
display: flex;
align-items: center;
}
.auth-section-attention {
color: #c0392b;
background: #fef5f5;
border-bottom-color: #fce4e4;
}
.auth-section-pending {
color: #d97706;
background: #fef9f0;
border-bottom-color: #fdecd0;
}
/* ---- Case List Items ---- */
.auth-case-list {
padding: 0;
}
.auth-case-item {
display: flex;
align-items: center;
padding: 16px 20px;
text-decoration: none !important;
color: inherit !important;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s ease;
cursor: pointer;
}
.auth-case-item:last-child {
border-bottom: none;
}
.auth-case-item:hover {
background: #f9fafb;
}
.auth-case-item:active {
background: #f0f2f5;
}
.auth-case-attention {
border-left: 3px solid #e74c3c;
}
.auth-case-main {
flex: 1;
min-width: 0;
}
.auth-case-client {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.auth-case-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 5px;
align-items: center;
}
.auth-case-ref {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.auth-case-type {
font-size: 11px;
background: #e3f2fd;
color: #1565c0;
padding: 2px 10px;
border-radius: 10px;
font-weight: 500;
text-transform: uppercase;
}
.auth-case-status {
font-size: 11px;
background: #f3e5f5;
color: #7b1fa2;
padding: 2px 10px;
border-radius: 10px;
font-weight: 500;
}
.badge-attention {
font-size: 11px;
background: #fce4ec;
color: #c62828;
padding: 2px 10px;
border-radius: 10px;
font-weight: 600;
}
.auth-case-date {
font-size: 12px;
color: #9ca3af;
}
.auth-case-arrow {
color: #c5ccd6;
font-size: 14px;
flex-shrink: 0;
margin-left: 12px;
}
/* ---- Empty State ---- */
.auth-empty-state {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.auth-empty-state i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
.auth-empty-state h5 {
color: #666;
margin-bottom: 8px;
}
/* ---- Desktop Enhancements ---- */
@media (min-width: 768px) {
.auth-dash-header-inner {
padding: 32px 36px 28px;
}
.auth-dash-greeting {
font-size: 28px;
}
.auth-dash-tiles {
flex-direction: row;
gap: 16px;
}
.auth-tile {
flex: 1;
padding: 20px 22px;
}
.auth-dash-content {
padding-top: 28px;
}
}
/* ---- Mobile Optimizations ---- */
@media (max-width: 767px) {
.auth-dash-content {
padding-left: 12px;
padding-right: 12px;
padding-top: 16px;
}
.auth-dash-header {
border-radius: 0;
margin-left: -12px;
margin-right: -12px;
margin-top: -24px;
margin-bottom: 20px;
}
.auth-dash-header-inner {
padding: 22px 20px 20px;
}
.auth-dash-greeting {
font-size: 20px;
}
.auth-dash-subtitle {
font-size: 13px;
}
.auth-tile {
padding: 16px 18px;
min-height: 66px;
}
.auth-tile-icon {
width: 44px;
height: 44px;
font-size: 18px;
margin-right: 14px;
}
.auth-case-item {
padding: 14px 18px;
}
.auth-section-header {
padding: 14px 18px;
}
}

View File

@@ -0,0 +1,540 @@
/* ==========================================================================
Fusion Technician Portal - Mobile-First Styles (v2)
========================================================================== */
/* ---- Base & Mobile First ---- */
.tech-portal {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
max-width: 640px;
margin: 0 auto;
}
/* ---- Quick Stats Bar (Dashboard) ---- */
.tech-stats-bar {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: none;
}
.tech-stats-bar::-webkit-scrollbar { display: none; }
.tech-stat-card {
flex: 0 0 auto;
min-width: 100px;
padding: 0.75rem 1rem;
border-radius: 12px;
text-align: center;
color: #fff;
font-weight: 600;
}
.tech-stat-card .stat-number {
font-size: 1.5rem;
line-height: 1.2;
}
.tech-stat-card .stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
}
.tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); }
.tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); }
.tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); }
.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); }
/* ---- Hero Card (Dashboard Current Task) ---- */
.tech-hero-card {
border: none;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 1.5rem;
}
.tech-hero-card .card-header {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
color: #fff;
padding: 1rem 1.25rem;
border: none;
}
.tech-hero-card .card-header h5 {
color: #fff;
margin: 0;
}
.tech-hero-card .card-body {
padding: 1.25rem;
}
.tech-hero-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.85;
margin-bottom: 0.15rem;
}
/* ---- Timeline (Dashboard) ---- */
.tech-timeline {
position: relative;
padding-left: 2rem;
}
.tech-timeline::before {
content: '';
position: absolute;
left: 0.75rem;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.tech-timeline-item {
position: relative;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
}
.tech-timeline-dot {
position: absolute;
left: -1.55rem;
top: 0.35rem;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #dee2e6;
z-index: 1;
}
.tech-timeline-dot.status-scheduled { background: #6c757d; box-shadow: 0 0 0 2px #6c757d; }
.tech-timeline-dot.status-en_route { background: #3498db; box-shadow: 0 0 0 2px #3498db; }
.tech-timeline-dot.status-in_progress { background: #f39c12; box-shadow: 0 0 0 2px #f39c12; animation: pulse-dot 1.5s infinite; }
.tech-timeline-dot.status-completed { background: #27ae60; box-shadow: 0 0 0 2px #27ae60; }
.tech-timeline-dot.status-cancelled { background: #e74c3c; box-shadow: 0 0 0 2px #e74c3c; }
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 2px #f39c12; }
50% { box-shadow: 0 0 0 6px rgba(243, 156, 18, 0.3); }
}
.tech-timeline-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 0.875rem 1rem;
background: #fff;
transition: box-shadow 0.2s, transform 0.15s;
text-decoration: none !important;
color: inherit !important;
display: block;
}
.tech-timeline-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.tech-timeline-card.active {
border-color: #f39c12;
border-width: 2px;
box-shadow: 0 4px 16px rgba(243, 156, 18, 0.15);
}
.tech-timeline-time {
font-size: 0.85rem;
font-weight: 600;
color: #495057;
}
.tech-timeline-title {
font-size: 0.95rem;
font-weight: 600;
color: #212529;
margin: 0.15rem 0;
}
.tech-timeline-meta {
font-size: 0.8rem;
color: #6c757d;
}
/* Travel indicator between tasks */
.tech-travel-indicator {
padding: 0.35rem 0 0.35rem 0;
margin-left: -0.2rem;
font-size: 0.75rem;
color: #8e44ad;
}
/* ---- Task Type Badges ---- */
.tech-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tech-badge-delivery { background: #d4edda; color: #155724; }
.tech-badge-repair { background: #fff3cd; color: #856404; }
.tech-badge-pickup { background: #cce5ff; color: #004085; }
.tech-badge-troubleshoot { background: #f8d7da; color: #721c24; }
.tech-badge-assessment { background: #e2e3e5; color: #383d41; }
.tech-badge-installation { background: #d1ecf1; color: #0c5460; }
.tech-badge-maintenance { background: #e8daef; color: #6c3483; }
.tech-badge-other { background: #e9ecef; color: #495057; }
/* Status badges */
.tech-status-badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.tech-status-scheduled { background: #e9ecef; color: #495057; }
.tech-status-en_route { background: #cce5ff; color: #004085; }
.tech-status-in_progress { background: #fff3cd; color: #856404; }
.tech-status-completed { background: #d4edda; color: #155724; }
.tech-status-cancelled { background: #f8d7da; color: #721c24; }
/* ==========================================================================
Task Detail Page - v2 Redesign
========================================================================== */
/* ---- Back button ---- */
.tech-back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--o-main-bg-color, #f8f9fa);
color: var(--o-main-text-color, #495057);
text-decoration: none !important;
transition: background 0.15s;
border: 1px solid var(--o-main-border-color, #dee2e6);
}
.tech-back-btn:hover {
background: var(--o-main-border-color, #dee2e6);
}
/* ---- Task Hero Header ---- */
.tech-task-hero {
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--o-main-border-color, #eee);
}
/* ---- Quick Actions Row ---- */
.tech-quick-actions {
display: flex;
gap: 0.75rem;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
padding: 0.25rem 0;
}
.tech-quick-actions::-webkit-scrollbar { display: none; }
.tech-quick-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-width: 68px;
padding: 0.6rem 0.5rem;
border-radius: 14px;
background: var(--o-main-bg-color, #f8f9fa);
border: 1px solid var(--o-main-border-color, #e9ecef);
color: var(--o-main-text-color, #495057) !important;
text-decoration: none !important;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
transition: all 0.15s;
flex-shrink: 0;
}
.tech-quick-btn i {
font-size: 1.15rem;
color: #3498db;
}
.tech-quick-btn:hover {
background: #e3f2fd;
border-color: #90caf9;
}
.tech-quick-btn:active {
transform: scale(0.95);
}
/* ---- Card (unified style for all sections) ---- */
.tech-card {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 1rem;
}
.tech-card-success {
border-color: #c3e6cb;
background: color-mix(in srgb, #d4edda 30%, var(--o-main-card-bg, #fff));
}
/* ---- Card icon (left gutter icon) ---- */
.tech-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
font-size: 1rem;
margin-right: 0.75rem;
flex-shrink: 0;
}
/* ---- Equipment highlight tag ---- */
.tech-equipment-tag {
background: color-mix(in srgb, #ffeeba 25%, var(--o-main-card-bg, #fff));
border: 1px solid #ffeeba;
border-radius: 10px;
padding: 0.75rem;
}
/* ---- Action Buttons (Large Touch Targets) ---- */
.tech-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 48px;
padding: 0.75rem 1.5rem;
border-radius: 14px;
font-weight: 600;
font-size: 0.95rem;
border: none;
cursor: pointer;
transition: all 0.15s;
text-decoration: none !important;
}
.tech-action-btn:active { transform: scale(0.97); }
.tech-btn-navigate {
background: #3498db;
color: #fff !important;
}
.tech-btn-navigate:hover { background: #2980b9; color: #fff !important; }
.tech-btn-start {
background: #27ae60;
color: #fff !important;
}
.tech-btn-start:hover { background: #219a52; color: #fff !important; }
.tech-btn-complete {
background: #f39c12;
color: #fff !important;
}
.tech-btn-complete:hover { background: #e67e22; color: #fff !important; }
.tech-btn-call {
background: #9b59b6;
color: #fff !important;
}
.tech-btn-call:hover { background: #8e44ad; color: #fff !important; }
.tech-btn-enroute {
background: #2980b9;
color: #fff !important;
}
.tech-btn-enroute:hover { background: #2471a3; color: #fff !important; }
/* ---- Bottom Action Bar (Fixed on mobile) ---- */
.tech-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--o-main-card-bg, #fff);
border-top: 1px solid var(--o-main-border-color, #dee2e6);
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
z-index: 1050;
display: flex;
gap: 0.5rem;
box-shadow: 0 -4px 20px rgba(0,0,0,0.08);
}
.tech-bottom-bar .tech-action-btn {
flex: 1;
}
/* Padding to prevent content being hidden behind fixed bar */
.has-bottom-bar {
padding-bottom: 5rem;
}
/* ---- Completion Overlay ---- */
.tech-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 9999;
align-items: center;
justify-content: center;
}
.tech-overlay-card {
background: var(--o-main-card-bg, #fff);
border-radius: 20px;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
animation: slideUp 0.3s ease;
}
.tech-overlay-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- Voice Recording UI ---- */
.tech-voice-recorder {
border: 2px dashed var(--o-main-border-color, #dee2e6);
border-radius: 16px;
padding: 1.5rem 1rem;
text-align: center;
transition: all 0.3s;
}
.tech-voice-recorder.recording {
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.04);
}
.tech-record-btn {
width: 64px;
height: 64px;
border-radius: 50%;
border: none;
background: #e74c3c;
color: #fff;
font-size: 1.3rem;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tech-record-btn:hover { transform: scale(1.05); }
.tech-record-btn:active { transform: scale(0.95); }
.tech-record-btn.recording {
animation: pulse-record 1.5s infinite;
}
@keyframes pulse-record {
0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); }
50% { box-shadow: 0 0 0 15px rgba(231, 76, 60, 0); }
}
.tech-record-timer {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 0.5rem;
color: #e74c3c;
}
/* ---- Tomorrow Prep ---- */
.tech-prep-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
background: #fff;
}
.tech-prep-card .prep-time {
font-weight: 700;
font-size: 0.9rem;
}
.tech-prep-card .prep-type {
margin-left: 0.5rem;
}
.tech-prep-equipment {
background: #fff9e6;
border: 1px solid #ffeeba;
border-radius: 12px;
padding: 1rem;
}
/* ---- Responsive: Desktop enhancements ---- */
@media (min-width: 768px) {
.tech-stats-bar {
gap: 1rem;
}
.tech-stat-card {
min-width: 130px;
padding: 1rem 1.5rem;
}
.tech-stat-card .stat-number {
font-size: 2rem;
}
.tech-bottom-bar {
position: static;
box-shadow: none;
border: none;
padding: 0;
margin-top: 1rem;
}
.has-bottom-bar {
padding-bottom: 0;
}
.tech-timeline {
padding-left: 3rem;
}
.tech-timeline::before {
left: 1.25rem;
}
.tech-timeline-dot {
left: -2.05rem;
}
.tech-quick-btn {
min-width: 80px;
padding: 0.75rem 0.75rem;
}
}
/* ---- Legacy detail section support ---- */
.tech-detail-section {
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px;
padding: 1rem;
margin-bottom: 1rem;
}
.tech-detail-section h6 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6c757d;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--o-main-border-color, #f1f3f5);
}
.tech-detail-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
}
.tech-detail-label {
font-weight: 500;
color: var(--o-main-text-color, #495057);
font-size: 0.9rem;
}
.tech-detail-value {
color: var(--o-main-text-color, #212529);
font-size: 0.9rem;
text-align: right;
}

View File

@@ -0,0 +1,109 @@
/**
* Fusion Authorizer Portal - Assessment Form
*/
odoo.define('fusion_authorizer_portal.assessment_form', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.AssessmentForm = publicWidget.Widget.extend({
selector: '#assessment-form',
events: {
'change input, change select, change textarea': '_onFieldChange',
'submit': '_onSubmit',
},
init: function () {
this._super.apply(this, arguments);
this.hasUnsavedChanges = false;
},
start: function () {
this._super.apply(this, arguments);
this._initializeForm();
return Promise.resolve();
},
_initializeForm: function () {
var self = this;
// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', function (e) {
if (self.hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
return '';
}
});
// Auto-fill full name from first + last name
var firstNameInput = this.el.querySelector('[name="client_first_name"]');
var lastNameInput = this.el.querySelector('[name="client_last_name"]');
var fullNameInput = this.el.querySelector('[name="client_name"]');
if (firstNameInput && lastNameInput && fullNameInput) {
var updateFullName = function () {
var first = firstNameInput.value.trim();
var last = lastNameInput.value.trim();
if (first || last) {
fullNameInput.value = (first + ' ' + last).trim();
}
};
firstNameInput.addEventListener('blur', updateFullName);
lastNameInput.addEventListener('blur', updateFullName);
}
// Number input validation
var numberInputs = this.el.querySelectorAll('input[type="number"]');
numberInputs.forEach(function (input) {
input.addEventListener('input', function () {
var value = parseFloat(this.value);
var min = parseFloat(this.min) || 0;
var max = parseFloat(this.max) || 9999;
if (value < min) this.value = min;
if (value > max) this.value = max;
});
});
},
_onFieldChange: function (ev) {
this.hasUnsavedChanges = true;
// Visual feedback that form has changes
var saveBtn = this.el.querySelector('button[value="save"]');
if (saveBtn) {
saveBtn.classList.add('btn-warning');
saveBtn.classList.remove('btn-primary');
}
},
_onSubmit: function (ev) {
// Validate required fields
var requiredFields = this.el.querySelectorAll('[required]');
var isValid = true;
requiredFields.forEach(function (field) {
if (!field.value.trim()) {
field.classList.add('is-invalid');
isValid = false;
} else {
field.classList.remove('is-invalid');
}
});
if (!isValid) {
ev.preventDefault();
alert('Please fill in all required fields.');
return false;
}
this.hasUnsavedChanges = false;
return true;
}
});
return publicWidget.registry.AssessmentForm;
});

View File

@@ -0,0 +1,37 @@
/** @odoo-module **/
// Fusion Authorizer Portal - Message Authorizer Chatter Button
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
//
// Patches the Chatter component to add a "Message Authorizer" button
// that opens the mail composer targeted at the assigned authorizer.
import { Chatter } from "@mail/chatter/web_portal/chatter";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(Chatter.prototype, {
setup() {
super.setup(...arguments);
this._fapActionService = useService("action");
this._fapOrm = useService("orm");
},
async onClickMessageAuthorizer() {
const thread = this.state.thread;
if (!thread || thread.model !== "sale.order") return;
try {
const result = await this._fapOrm.call(
"sale.order",
"action_message_authorizer",
[thread.id],
);
if (result && result.type === "ir.actions.act_window") {
this._fapActionService.doAction(result);
}
} catch (e) {
console.warn("Message Authorizer action failed:", e);
}
},
});

View File

@@ -0,0 +1,478 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.LoanerPortal = publicWidget.Widget.extend({
selector: '#loanerSection, #btn_checkout_loaner, .btn-loaner-return',
start: function () {
this._super.apply(this, arguments);
this._allProducts = [];
this._initLoanerSection();
this._initCheckoutButton();
this._initReturnButtons();
this._initModal();
},
// =====================================================================
// MODAL: Initialize and wire up the loaner checkout modal
// =====================================================================
_initModal: function () {
var self = this;
var modal = document.getElementById('loanerCheckoutModal');
if (!modal) return;
var categorySelect = document.getElementById('modal_category_id');
var productSelect = document.getElementById('modal_product_id');
var lotSelect = document.getElementById('modal_lot_id');
var loanDays = document.getElementById('modal_loan_days');
var btnCheckout = document.getElementById('modal_btn_checkout');
var btnCreateProduct = document.getElementById('modal_btn_create_product');
var newCategorySelect = document.getElementById('modal_new_category_id');
var createResult = document.getElementById('modal_create_result');
// Load categories when modal opens
modal.addEventListener('show.bs.modal', function () {
self._loadCategories(categorySelect, newCategorySelect);
self._loadProducts(null, productSelect, lotSelect);
});
// Category change -> filter products
if (categorySelect) {
categorySelect.addEventListener('change', function () {
var catId = this.value ? parseInt(this.value) : null;
self._filterProducts(catId, productSelect, lotSelect);
});
}
// Product change -> filter lots
if (productSelect) {
productSelect.addEventListener('change', function () {
var prodId = this.value ? parseInt(this.value) : null;
self._filterLots(prodId, lotSelect, loanDays);
});
}
// Quick Create Product
if (btnCreateProduct) {
btnCreateProduct.addEventListener('click', function () {
var name = document.getElementById('modal_new_product_name').value.trim();
var serial = document.getElementById('modal_new_serial').value.trim();
var catId = newCategorySelect ? newCategorySelect.value : '';
if (!name || !serial) {
alert('Please enter both product name and serial number.');
return;
}
btnCreateProduct.disabled = true;
btnCreateProduct.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name,
serial_number: serial,
category_id: catId || null,
}).then(function (result) {
if (result.success) {
// Add to product dropdown
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
// Add to lots
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
// Add to internal data
self._allProducts.push({
id: result.product_id,
name: result.product_name,
category_id: catId ? parseInt(catId) : null,
period_days: 7,
lots: [{ id: result.lot_id, name: result.lot_name }],
});
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2"><i class="fa fa-check me-1"></i> "' + result.product_name + '" (S/N: ' + result.lot_name + ') created!</div>';
}
// Clear fields
document.getElementById('modal_new_product_name').value = '';
document.getElementById('modal_new_serial').value = '';
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreateProduct.disabled = false;
btnCreateProduct.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
// Checkout button
if (btnCheckout) {
btnCheckout.addEventListener('click', function () {
var productId = productSelect.value ? parseInt(productSelect.value) : null;
var lotId = lotSelect.value ? parseInt(lotSelect.value) : null;
var days = parseInt(loanDays.value) || 7;
var orderId = document.getElementById('modal_order_id').value;
var clientId = document.getElementById('modal_client_id').value;
if (!productId) {
alert('Please select a product.');
return;
}
btnCheckout.disabled = true;
btnCheckout.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/checkout', {
product_id: productId,
lot_id: lotId,
sale_order_id: orderId ? parseInt(orderId) : null,
client_id: clientId ? parseInt(clientId) : null,
loaner_period_days: days,
checkout_condition: 'good',
checkout_notes: '',
}).then(function (result) {
if (result.success) {
self._hideModal(modal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnCheckout.disabled = false;
btnCheckout.innerHTML = '<i class="fa fa-check me-1"></i> Checkout Loaner';
}
});
});
}
},
_loadCategories: function (categorySelect, newCategorySelect) {
this._rpc('/my/loaner/categories', {}).then(function (categories) {
categories = categories || [];
// Main category dropdown
if (categorySelect) {
categorySelect.innerHTML = '<option value="">All Categories</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
categorySelect.appendChild(opt);
});
}
// Quick create category dropdown
if (newCategorySelect) {
newCategorySelect.innerHTML = '<option value="">-- Select --</option>';
categories.forEach(function (c) {
var opt = document.createElement('option');
opt.value = c.id;
opt.text = c.name;
newCategorySelect.appendChild(opt);
});
}
});
},
_loadProducts: function (categoryId, productSelect, lotSelect) {
var self = this;
var params = {};
if (categoryId) params.category_id = categoryId;
this._rpc('/my/loaner/products', params).then(function (products) {
self._allProducts = products || [];
self._renderProducts(self._allProducts, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
});
},
_filterProducts: function (categoryId, productSelect, lotSelect) {
var filtered = this._allProducts;
if (categoryId) {
filtered = this._allProducts.filter(function (p) { return p.category_id === categoryId; });
}
this._renderProducts(filtered, productSelect);
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
},
_renderProducts: function (products, productSelect) {
if (!productSelect) return;
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
products.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
},
_filterLots: function (productId, lotSelect, loanDays) {
if (!lotSelect) return;
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
if (!productId) return;
var product = this._allProducts.find(function (p) { return p.id === productId; });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (loanDays && product.period_days) {
loanDays.value = product.period_days;
}
}
},
// =====================================================================
// CHECKOUT BUTTON: Opens the modal
// =====================================================================
_initCheckoutButton: function () {
var self = this;
var btns = document.querySelectorAll('#btn_checkout_loaner');
btns.forEach(function (btn) {
btn.addEventListener('click', function () {
var orderId = btn.dataset.orderId || '';
var clientId = btn.dataset.clientId || '';
// Set context in modal
var modalOrderId = document.getElementById('modal_order_id');
var modalClientId = document.getElementById('modal_client_id');
if (modalOrderId) modalOrderId.value = orderId;
if (modalClientId) modalClientId.value = clientId;
// Show modal
var modal = document.getElementById('loanerCheckoutModal');
self._showModal(modal);
});
});
},
// =====================================================================
// RETURN BUTTONS
// =====================================================================
_initReturnButtons: function () {
var self = this;
var returnModal = document.getElementById('loanerReturnModal');
if (!returnModal) return;
var btnSubmitReturn = document.getElementById('return_modal_btn_submit');
document.querySelectorAll('.btn-loaner-return').forEach(function (btn) {
btn.addEventListener('click', function () {
var checkoutId = parseInt(btn.dataset.checkoutId);
var productName = btn.dataset.productName || 'Loaner';
// Set modal values
document.getElementById('return_modal_checkout_id').value = checkoutId;
document.getElementById('return_modal_product_name').textContent = productName;
document.getElementById('return_modal_condition').value = 'good';
document.getElementById('return_modal_notes').value = '';
// Load locations
var locSelect = document.getElementById('return_modal_location_id');
locSelect.innerHTML = '<option value="">-- Loading... --</option>';
self._rpc('/my/loaner/locations', {}).then(function (locations) {
locations = locations || [];
locSelect.innerHTML = '<option value="">-- Select Location --</option>';
locations.forEach(function (l) {
var opt = document.createElement('option');
opt.value = l.id;
opt.text = l.name;
locSelect.appendChild(opt);
});
});
// Show modal
self._showModal(returnModal);
});
});
// Submit return
if (btnSubmitReturn) {
btnSubmitReturn.addEventListener('click', function () {
var checkoutId = parseInt(document.getElementById('return_modal_checkout_id').value);
var condition = document.getElementById('return_modal_condition').value;
var notes = document.getElementById('return_modal_notes').value;
var locationId = document.getElementById('return_modal_location_id').value;
btnSubmitReturn.disabled = true;
btnSubmitReturn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
self._rpc('/my/loaner/return', {
checkout_id: checkoutId,
return_condition: condition,
return_notes: notes,
return_location_id: locationId ? parseInt(locationId) : null,
}).then(function (result) {
if (result.success) {
self._hideModal(returnModal);
alert(result.message);
location.reload();
} else {
alert('Error: ' + (result.error || 'Unknown'));
btnSubmitReturn.disabled = false;
btnSubmitReturn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm Return';
}
});
});
}
},
// =====================================================================
// EXPRESS ASSESSMENT: Loaner Section
// =====================================================================
_initLoanerSection: function () {
var self = this;
var loanerSection = document.getElementById('loanerSection');
if (!loanerSection) return;
var productSelect = document.getElementById('loaner_product_id');
var lotSelect = document.getElementById('loaner_lot_id');
var periodInput = document.getElementById('loaner_period_days');
var checkoutFlag = document.getElementById('loaner_checkout');
var existingFields = document.getElementById('loaner_existing_fields');
var newFields = document.getElementById('loaner_new_fields');
var modeRadios = document.querySelectorAll('input[name="loaner_mode"]');
var btnCreate = document.getElementById('btn_create_loaner_product');
var createResult = document.getElementById('loaner_create_result');
var productsData = [];
loanerSection.addEventListener('show.bs.collapse', function () {
if (productSelect && productSelect.options.length <= 1) {
self._rpc('/my/loaner/products', {}).then(function (data) {
productsData = data || [];
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
productsData.forEach(function (p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.text = p.name + ' (' + p.lots.length + ' avail)';
productSelect.appendChild(opt);
});
});
}
});
loanerSection.addEventListener('shown.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '1';
});
loanerSection.addEventListener('hidden.bs.collapse', function () {
if (checkoutFlag) checkoutFlag.value = '0';
});
modeRadios.forEach(function (radio) {
radio.addEventListener('change', function () {
if (this.value === 'existing') {
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
} else {
if (existingFields) existingFields.style.display = 'none';
if (newFields) newFields.style.display = '';
}
});
});
if (productSelect) {
productSelect.addEventListener('change', function () {
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
var product = productsData.find(function (p) { return p.id === parseInt(productSelect.value); });
if (product) {
product.lots.forEach(function (lot) {
var opt = document.createElement('option');
opt.value = lot.id;
opt.text = lot.name;
lotSelect.appendChild(opt);
});
if (periodInput && product.period_days) periodInput.value = product.period_days;
}
});
}
if (btnCreate) {
btnCreate.addEventListener('click', function () {
var name = document.getElementById('loaner_new_product_name').value.trim();
var serial = document.getElementById('loaner_new_serial').value.trim();
if (!name || !serial) { alert('Enter both name and serial.'); return; }
btnCreate.disabled = true;
btnCreate.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
self._rpc('/my/loaner/create-product', {
product_name: name, serial_number: serial,
}).then(function (result) {
if (result.success) {
var opt = document.createElement('option');
opt.value = result.product_id;
opt.text = result.product_name;
opt.selected = true;
productSelect.appendChild(opt);
lotSelect.innerHTML = '';
var lotOpt = document.createElement('option');
lotOpt.value = result.lot_id;
lotOpt.text = result.lot_name;
lotOpt.selected = true;
lotSelect.appendChild(lotOpt);
document.getElementById('loaner_existing').checked = true;
if (existingFields) existingFields.style.display = '';
if (newFields) newFields.style.display = 'none';
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-success py-2">Created "' + result.product_name + '" (S/N: ' + result.lot_name + ')</div>';
}
} else {
if (createResult) {
createResult.style.display = '';
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
}
}
btnCreate.disabled = false;
btnCreate.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
});
});
}
},
// =====================================================================
// HELPERS
// =====================================================================
_rpc: function (url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params }),
}).then(function (r) { return r.json(); }).then(function (d) { return d.result; });
},
_showModal: function (modalEl) {
if (!modalEl) return;
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal) {
var inst = Modal.getOrCreateInstance ? Modal.getOrCreateInstance(modalEl) : new Modal(modalEl);
inst.show();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('show');
}
},
_hideModal: function (modalEl) {
if (!modalEl) return;
try {
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
if (Modal && Modal.getInstance) {
var inst = Modal.getInstance(modalEl);
if (inst) inst.hide();
} else if (window.$ || window.jQuery) {
(window.$ || window.jQuery)(modalEl).modal('hide');
}
} catch (e) { /* non-blocking */ }
},
});

View File

@@ -0,0 +1,716 @@
/**
* Fusion PDF Field Position Editor
*
* Features:
* - Drag field types from sidebar palette onto PDF to create new fields
* - Drag existing fields to reposition them
* - Resize handles on each field (bottom-right corner)
* - Click to select and edit properties in right panel
* - Percentage-based positions (0.0-1.0), same as Odoo Sign module
* - Auto-save on every drag/resize
*/
document.addEventListener('DOMContentLoaded', function () {
'use strict';
var editor = document.getElementById('pdf_field_editor');
if (!editor) return;
var templateId = parseInt(editor.dataset.templateId);
var pageCount = parseInt(editor.dataset.pageCount) || 1;
var templateCategory = editor.dataset.category || 'other';
var currentPage = 1;
var fields = {};
var selectedFieldId = null;
var fieldCounter = 0;
var container = document.getElementById('pdf_canvas_container');
var pageImage = document.getElementById('pdf_page_image');
// ================================================================
// Colors per field type
// ================================================================
// ================================================================
// Available data keys, organized by template category
// ================================================================
var COMMON_KEYS = [
{ group: 'Client Info', keys: [
{ key: 'client_last_name', label: 'Last Name' },
{ key: 'client_first_name', label: 'First Name' },
{ key: 'client_middle_name', label: 'Middle Name' },
{ key: 'client_name', label: 'Full Name' },
{ key: 'client_street', label: 'Street' },
{ key: 'client_unit', label: 'Unit/Apt' },
{ key: 'client_city', label: 'City' },
{ key: 'client_state', label: 'Province' },
{ key: 'client_postal_code', label: 'Postal Code' },
{ key: 'client_phone', label: 'Phone' },
{ key: 'client_email', label: 'Email' },
]},
];
var CATEGORY_KEYS = {
adp: [
{ group: 'ADP - Client Details', keys: [
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'ADP - Client Type', keys: [
{ key: 'client_type_reg', label: 'REG Checkbox' },
{ key: 'client_type_ods', label: 'ODS Checkbox' },
{ key: 'client_type_acs', label: 'ACS Checkbox' },
{ key: 'client_type_owp', label: 'OWP Checkbox' },
]},
{ group: 'ADP - Consent', keys: [
{ key: 'consent_applicant', label: 'Applicant Checkbox' },
{ key: 'consent_agent', label: 'Agent Checkbox' },
{ key: 'consent_date', label: 'Consent Date' },
]},
{ group: 'ADP - Agent Relationship', keys: [
{ key: 'agent_rel_spouse', label: 'Spouse Checkbox' },
{ key: 'agent_rel_parent', label: 'Parent Checkbox' },
{ key: 'agent_rel_child', label: 'Child Checkbox' },
{ key: 'agent_rel_poa', label: 'POA Checkbox' },
{ key: 'agent_rel_guardian', label: 'Guardian Checkbox' },
]},
{ group: 'ADP - Agent Info', keys: [
{ key: 'agent_last_name', label: 'Agent Last Name' },
{ key: 'agent_first_name', label: 'Agent First Name' },
{ key: 'agent_middle_initial', label: 'Agent Middle Initial' },
{ key: 'agent_unit', label: 'Agent Unit' },
{ key: 'agent_street_number', label: 'Agent Street No.' },
{ key: 'agent_street_name', label: 'Agent Street Name' },
{ key: 'agent_city', label: 'Agent City' },
{ key: 'agent_province', label: 'Agent Province' },
{ key: 'agent_postal_code', label: 'Agent Postal Code' },
{ key: 'agent_home_phone', label: 'Agent Home Phone' },
{ key: 'agent_business_phone', label: 'Agent Business Phone' },
{ key: 'agent_phone_ext', label: 'Agent Phone Ext' },
]},
{ group: 'ADP - Equipment', keys: [
{ key: 'equipment_type', label: 'Equipment Type' },
{ key: 'seat_width', label: 'Seat Width' },
{ key: 'seat_depth', label: 'Seat Depth' },
{ key: 'seat_to_floor_height', label: 'Seat to Floor Height' },
{ key: 'back_height', label: 'Back Height' },
{ key: 'legrest_length', label: 'Legrest Length' },
{ key: 'cane_height', label: 'Cane Height' },
]},
{ group: 'ADP - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
{ key: 'claim_authorization_date', label: 'Authorization Date' },
]},
{ group: 'ADP - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
{ group: 'ADP - Signatures', keys: [
{ key: 'signature_page_11', label: 'Page 11 Signature' },
{ key: 'signature_page_12', label: 'Page 12 Signature' },
]},
{ group: 'ADP - Other', keys: [
{ key: 'reference', label: 'Assessment Reference' },
{ key: 'reason_for_application', label: 'Reason for Application' },
]},
],
odsp: [
{ group: 'ODSP - Signing Fields', keys: [
{ key: 'sa_client_name', label: 'Client Name (signing)' },
{ key: 'sa_sign_date', label: 'Signing Date' },
{ key: 'sa_signature', label: 'Client Signature' },
]},
{ group: 'ODSP - Client Details', keys: [
{ key: 'client_health_card', label: 'Health Card Number' },
{ key: 'client_health_card_version', label: 'Health Card Version' },
{ key: 'client_weight', label: 'Weight (lbs)' },
]},
{ group: 'ODSP - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'ODSP - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
mod: [
{ group: 'MOD - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'MOD - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
hardship: [
{ group: 'Hardship - Dates', keys: [
{ key: 'assessment_start_date', label: 'Assessment Start Date' },
{ key: 'assessment_end_date', label: 'Assessment End Date' },
]},
{ group: 'Hardship - Authorizer', keys: [
{ key: 'authorizer_name', label: 'Authorizer Name' },
{ key: 'authorizer_phone', label: 'Authorizer Phone' },
{ key: 'authorizer_email', label: 'Authorizer Email' },
]},
],
};
var DATA_KEYS = COMMON_KEYS.concat(CATEGORY_KEYS[templateCategory] || []);
// Build a flat lookup: key -> label
var KEY_LABELS = {};
DATA_KEYS.forEach(function (g) {
g.keys.forEach(function (k) { KEY_LABELS[k.key] = k.label; });
});
// Build <option> HTML for the data key dropdown
function buildDataKeyOptions(selectedKey) {
var html = '<option value="">(custom / none)</option>';
DATA_KEYS.forEach(function (g) {
html += '<optgroup label="' + g.group + '">';
g.keys.forEach(function (k) {
html += '<option value="' + k.key + '"'
+ (k.key === selectedKey ? ' selected' : '')
+ '>' + k.label + ' (' + k.key + ')</option>';
});
html += '</optgroup>';
});
return html;
}
var COLORS = {
text: { bg: 'rgba(52,152,219,0.25)', border: '#3498db' },
checkbox: { bg: 'rgba(46,204,113,0.25)', border: '#2ecc71' },
date: { bg: 'rgba(230,126,34,0.25)', border: '#e67e22' },
signature: { bg: 'rgba(155,89,182,0.25)', border: '#9b59b6' },
};
var DEFAULT_SIZES = {
text: { w: 0.150, h: 0.018 },
checkbox: { w: 0.018, h: 0.018 },
date: { w: 0.120, h: 0.018 },
signature: { w: 0.200, h: 0.050 },
};
// ================================================================
// JSONRPC helper
// ================================================================
function jsonrpc(url, params) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params || {} })
}).then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { console.error('RPC error', d.error); return null; }
return d.result;
});
}
// ================================================================
// Init
// ================================================================
function init() {
loadFields();
setupPageNavigation();
setupPaletteDrag();
setupContainerDrop();
setupPreviewButton();
buildDataKeysSidebar();
// Prevent the image from intercepting drag events
if (pageImage) {
pageImage.style.pointerEvents = 'none';
}
// Also prevent any child (except field markers) from blocking drops
container.querySelectorAll('#no_preview_placeholder').forEach(function (el) {
el.style.pointerEvents = 'none';
});
}
function buildDataKeysSidebar() {
var list = document.getElementById('dataKeysList');
if (!list) return;
var html = '';
DATA_KEYS.forEach(function (g) {
html += '<div class="mb-1 mt-2"><strong>' + g.group + ':</strong></div>';
g.keys.forEach(function (k) {
html += '<code class="d-block">' + k.key + '</code>';
});
});
list.innerHTML = html;
}
// ================================================================
// Load fields
// ================================================================
function loadFields() {
jsonrpc('/fusion/pdf-editor/fields', { template_id: templateId }).then(function (result) {
if (!result) return;
fields = {};
result.forEach(function (f) { fields[f.id] = f; fieldCounter++; });
renderFieldsForPage(currentPage);
});
}
// ================================================================
// Render fields on current page
// ================================================================
function renderFieldsForPage(page) {
container.querySelectorAll('.pdf-field-marker').forEach(function (el) { el.remove(); });
Object.values(fields).forEach(function (f) {
if (f.page === page) renderFieldMarker(f);
});
updateFieldCount();
}
function renderFieldMarker(field) {
var c = COLORS[field.field_type] || COLORS.text;
var marker = document.createElement('div');
marker.className = 'pdf-field-marker';
marker.dataset.fieldId = field.id;
marker.setAttribute('draggable', 'true');
Object.assign(marker.style, {
position: 'absolute',
left: (field.pos_x * 100) + '%',
top: (field.pos_y * 100) + '%',
width: (field.width * 100) + '%',
height: Math.max(field.height * 100, 1.5) + '%',
backgroundColor: c.bg,
border: '2px solid ' + c.border,
borderRadius: '3px',
cursor: 'move',
display: 'flex',
alignItems: 'center',
fontSize: '10px',
color: '#333',
fontWeight: '600',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
padding: '0 4px',
zIndex: 10,
boxSizing: 'border-box',
userSelect: 'none',
});
// Label text
var label = document.createElement('span');
label.style.pointerEvents = 'none';
label.style.flex = '1';
label.style.overflow = 'hidden';
label.style.textOverflow = 'ellipsis';
label.textContent = field.label || field.name;
marker.appendChild(label);
// Resize handle (bottom-right corner)
var handle = document.createElement('div');
Object.assign(handle.style, {
position: 'absolute',
right: '0',
bottom: '0',
width: '10px',
height: '10px',
backgroundColor: c.border,
cursor: 'nwse-resize',
borderRadius: '2px 0 2px 0',
opacity: '0.7',
});
handle.className = 'resize-handle';
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
startResize(field.id, e);
});
marker.appendChild(handle);
// Tooltip
marker.title = (field.label || field.name) + '\nKey: ' + (field.field_key || 'unmapped') + '\nType: ' + field.field_type;
// Drag to reposition
marker.addEventListener('dragstart', function (e) { onFieldDragStart(e, field.id); });
marker.addEventListener('dragend', function (e) { e.target.style.opacity = ''; });
// Click to select
marker.addEventListener('click', function (e) {
e.stopPropagation();
selectField(field.id);
});
container.appendChild(marker);
// Highlight if selected
if (field.id === selectedFieldId) {
marker.style.boxShadow = '0 0 0 3px #007bff';
marker.style.zIndex = '20';
}
}
// ================================================================
// Drag existing fields to reposition
// ================================================================
var dragOffsetX = 0, dragOffsetY = 0;
var dragFieldId = null;
var dragSource = null; // 'field' or 'palette'
var dragFieldType = null;
function onFieldDragStart(e, fieldId) {
dragSource = 'field';
dragFieldId = fieldId;
var rect = e.target.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'field');
requestAnimationFrame(function () { e.target.style.opacity = '0.4'; });
}
// ================================================================
// Drag from palette to create new field
// ================================================================
function setupPaletteDrag() {
document.querySelectorAll('.pdf-palette-item').forEach(function (item) {
item.addEventListener('dragstart', function (e) {
dragSource = 'palette';
dragFieldType = e.currentTarget.dataset.fieldType;
dragFieldId = null;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', 'palette');
e.currentTarget.style.opacity = '0.5';
});
item.addEventListener('dragend', function (e) {
e.currentTarget.style.opacity = '';
});
});
}
// ================================================================
// Drop handler on PDF container
// ================================================================
function setupContainerDrop() {
// Must preventDefault on dragover for drop to fire
container.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = (dragSource === 'palette') ? 'copy' : 'move';
});
container.addEventListener('dragenter', function (e) {
e.preventDefault();
e.stopPropagation();
});
container.addEventListener('drop', function (e) {
e.preventDefault();
e.stopPropagation();
// Use the container rect as the reference area
// (the image has pointer-events:none, so we use the container which matches its size)
var rect = container.getBoundingClientRect();
if (dragSource === 'palette' && dragFieldType) {
// ---- CREATE new field at drop position ----
var defaults = DEFAULT_SIZES[dragFieldType] || DEFAULT_SIZES.text;
var posX = (e.clientX - rect.left) / rect.width;
var posY = (e.clientY - rect.top) / rect.height;
posX = normalize(posX, defaults.w);
posY = normalize(posY, defaults.h);
posX = round3(posX);
posY = round3(posY);
fieldCounter++;
var autoName = dragFieldType + '_' + fieldCounter;
var newField = {
template_id: templateId,
name: autoName,
label: autoName,
field_type: dragFieldType,
field_key: autoName,
page: currentPage,
pos_x: posX,
pos_y: posY,
width: defaults.w,
height: defaults.h,
font_size: 10,
};
jsonrpc('/fusion/pdf-editor/create-field', newField).then(function (res) {
if (res && res.id) {
newField.id = res.id;
fields[res.id] = newField;
renderFieldsForPage(currentPage);
selectField(res.id);
}
});
} else if (dragSource === 'field' && dragFieldId && fields[dragFieldId]) {
// ---- MOVE existing field ----
var field = fields[dragFieldId];
var posX = (e.clientX - rect.left - dragOffsetX) / rect.width;
var posY = (e.clientY - rect.top - dragOffsetY) / rect.height;
posX = normalize(posX, field.width);
posY = normalize(posY, field.height);
posX = round3(posX);
posY = round3(posY);
field.pos_x = posX;
field.pos_y = posY;
saveField(field.id, { pos_x: posX, pos_y: posY });
renderFieldsForPage(currentPage);
selectField(field.id);
}
dragSource = null;
dragFieldId = null;
dragFieldType = null;
});
}
// ================================================================
// Resize handles
// ================================================================
function startResize(fieldId, startEvent) {
var field = fields[fieldId];
if (!field) return;
var imgRect = container.getBoundingClientRect();
var startX = startEvent.clientX;
var startY = startEvent.clientY;
var startW = field.width;
var startH = field.height;
var marker = container.querySelector('[data-field-id="' + fieldId + '"]');
function onMove(e) {
var dx = (e.clientX - startX) / imgRect.width;
var dy = (e.clientY - startY) / imgRect.height;
var newW = Math.max(startW + dx, 0.010);
var newH = Math.max(startH + dy, 0.005);
// Clamp to page bounds
if (field.pos_x + newW > 1.0) newW = 1.0 - field.pos_x;
if (field.pos_y + newH > 1.0) newH = 1.0 - field.pos_y;
field.width = round3(newW);
field.height = round3(newH);
if (marker) {
marker.style.width = (field.width * 100) + '%';
marker.style.height = Math.max(field.height * 100, 1.5) + '%';
}
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveField(fieldId, { width: field.width, height: field.height });
renderFieldsForPage(currentPage);
selectField(fieldId);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ================================================================
// Select field and show properties
// ================================================================
function selectField(fieldId) {
selectedFieldId = fieldId;
var field = fields[fieldId];
if (!field) return;
// Re-render to update highlights
renderFieldsForPage(currentPage);
var panel = document.getElementById('field_props_body');
panel.innerHTML = ''
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Data Key</label>'
+ ' <select class="form-select form-select-sm" id="prop_field_key">'
+ buildDataKeyOptions(field.field_key || '')
+ ' </select>'
+ '</div>'
+ row('Name', 'text', 'prop_name', field.name || '')
+ row('Label', 'text', 'prop_label', field.label || '')
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Type</label>'
+ ' <select class="form-select form-select-sm" id="prop_type">'
+ ' <option value="text"' + sel(field.field_type, 'text') + '>Text</option>'
+ ' <option value="checkbox"' + sel(field.field_type, 'checkbox') + '>Checkbox</option>'
+ ' <option value="signature"' + sel(field.field_type, 'signature') + '>Signature</option>'
+ ' <option value="date"' + sel(field.field_type, 'date') + '>Date</option>'
+ ' </select>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Font Size', 'number', 'prop_font_size', field.font_size || 10, '0.5') + '</div>'
+ ' <div class="col-6">' + row('Page', 'number', 'prop_page', field.page || 1) + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6">' + row('Width', 'number', 'prop_width', field.width || 0.15, '0.005') + '</div>'
+ ' <div class="col-6">' + row('Height', 'number', 'prop_height', field.height || 0.015, '0.005') + '</div>'
+ '</div>'
+ '<div class="row mb-2">'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">X</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_x) + '" readonly/></div>'
+ ' <div class="col-6"><label class="form-label fw-bold small mb-0">Y</label>'
+ ' <input type="text" class="form-control form-control-sm" value="' + round3(field.pos_y) + '" readonly/></div>'
+ '</div>'
+ '<div class="mb-2">'
+ ' <label class="form-label fw-bold small mb-0">Text Align</label>'
+ ' <div class="btn-group w-100" role="group">'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'left' || !field.text_align ? ' active' : '') + '" data-align="left"><i class="fa fa-align-left"></i></button>'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'center' ? ' active' : '') + '" data-align="center"><i class="fa fa-align-center"></i></button>'
+ ' <button type="button" class="btn btn-sm btn-outline-secondary' + (field.text_align === 'right' ? ' active' : '') + '" data-align="right"><i class="fa fa-align-right"></i></button>'
+ ' </div>'
+ '</div>'
+ '<div class="d-flex gap-2 mt-3">'
+ ' <button type="button" class="btn btn-primary btn-sm flex-grow-1" id="btn_save_props">'
+ ' <i class="fa fa-save me-1"></i>Save</button>'
+ ' <button type="button" class="btn btn-outline-danger btn-sm" id="btn_delete_field">'
+ ' <i class="fa fa-trash me-1"></i>Delete</button>'
+ '</div>';
// Auto-fill name and label when data key is selected
document.getElementById('prop_field_key').addEventListener('change', function () {
var selectedKey = this.value;
if (selectedKey && KEY_LABELS[selectedKey]) {
document.getElementById('prop_name').value = selectedKey;
document.getElementById('prop_label').value = KEY_LABELS[selectedKey];
}
});
var alignBtns = panel.querySelectorAll('[data-align]');
alignBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
alignBtns.forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
});
});
document.getElementById('btn_save_props').addEventListener('click', function () {
var keySelect = document.getElementById('prop_field_key');
var selectedKey = keySelect ? keySelect.value : '';
var activeAlign = panel.querySelector('[data-align].active');
var vals = {
name: val('prop_name'),
label: val('prop_label'),
field_key: selectedKey,
field_type: val('prop_type'),
font_size: parseFloat(val('prop_font_size')) || 10,
page: parseInt(val('prop_page')) || 1,
width: parseFloat(val('prop_width')) || 0.15,
height: parseFloat(val('prop_height')) || 0.015,
text_align: activeAlign ? activeAlign.dataset.align : 'left',
};
Object.assign(field, vals);
saveField(fieldId, vals);
renderFieldsForPage(currentPage);
selectField(fieldId);
});
document.getElementById('btn_delete_field').addEventListener('click', function () {
if (!confirm('Delete "' + (field.label || field.name) + '"?')) return;
jsonrpc('/fusion/pdf-editor/delete-field', { field_id: fieldId }).then(function () {
delete fields[fieldId];
selectedFieldId = null;
renderFieldsForPage(currentPage);
panel.innerHTML = '<p class="text-muted small">Field deleted.</p>';
});
});
}
// ================================================================
// Save field to server
// ================================================================
function saveField(fieldId, values) {
jsonrpc('/fusion/pdf-editor/update-field', { field_id: fieldId, values: values });
}
// ================================================================
// Page navigation
// ================================================================
function setupPageNavigation() {
var prev = document.getElementById('btn_prev_page');
var next = document.getElementById('btn_next_page');
if (prev) prev.addEventListener('click', function () { if (currentPage > 1) switchPage(--currentPage); });
if (next) next.addEventListener('click', function () { if (currentPage < pageCount) switchPage(++currentPage); });
}
function switchPage(page) {
currentPage = page;
var d = document.getElementById('current_page_display');
if (d) d.textContent = page;
jsonrpc('/fusion/pdf-editor/page-image', { template_id: templateId, page: page }).then(function (r) {
if (r && r.image_url && pageImage) pageImage.src = r.image_url;
renderFieldsForPage(page);
});
}
// ================================================================
// Preview
// ================================================================
function setupPreviewButton() {
var btn = document.getElementById('btn_preview');
if (btn) btn.addEventListener('click', function () {
window.open('/fusion/pdf-editor/preview/' + templateId, '_blank');
});
}
// ================================================================
// Helpers
// ================================================================
function normalize(pos, dim) {
if (pos < 0) return 0;
if (pos + dim > 1.0) return 1.0 - dim;
return pos;
}
function round3(n) { return Math.round((n || 0) * 1000) / 1000; }
function val(id) { var el = document.getElementById(id); return el ? el.value : ''; }
function sel(current, option) { return current === option ? ' selected' : ''; }
function row(label, type, id, value, step) {
return '<div class="mb-2"><label class="form-label fw-bold small mb-0">' + label + '</label>'
+ '<input type="' + type + '" class="form-control form-control-sm" id="' + id + '"'
+ ' value="' + value + '"' + (step ? ' step="' + step + '"' : '') + '/></div>';
}
function updateFieldCount() {
var el = document.getElementById('field_count');
if (el) el.textContent = Object.keys(fields).length;
}
// ================================================================
// Start
// ================================================================
init();
});

View File

@@ -0,0 +1,161 @@
/**
* Fusion Authorizer Portal - Real-time Search
*/
odoo.define('fusion_authorizer_portal.portal_search', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
publicWidget.registry.PortalSearch = publicWidget.Widget.extend({
selector: '#portal-search-input',
events: {
'input': '_onSearchInput',
'keydown': '_onKeyDown',
},
init: function () {
this._super.apply(this, arguments);
this.debounceTimer = null;
this.searchEndpoint = this._getSearchEndpoint();
this.resultsContainer = null;
},
start: function () {
this._super.apply(this, arguments);
this.resultsContainer = document.getElementById('cases-table-body');
return Promise.resolve();
},
_getSearchEndpoint: function () {
// Determine which portal we're on
var path = window.location.pathname;
if (path.includes('/my/authorizer')) {
return '/my/authorizer/cases/search';
} else if (path.includes('/my/sales')) {
return '/my/sales/cases/search';
}
return null;
},
_onSearchInput: function (ev) {
var self = this;
var query = ev.target.value.trim();
clearTimeout(this.debounceTimer);
if (query.length < 2) {
// If query is too short, reload original page
return;
}
// Debounce - wait 250ms before searching
this.debounceTimer = setTimeout(function () {
self._performSearch(query);
}, 250);
},
_onKeyDown: function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
var query = ev.target.value.trim();
if (query.length >= 2) {
this._performSearch(query);
}
} else if (ev.key === 'Escape') {
ev.target.value = '';
window.location.reload();
}
},
_performSearch: function (query) {
var self = this;
if (!this.searchEndpoint) {
console.error('Search endpoint not found');
return;
}
// Show loading indicator
this._showLoading(true);
ajax.jsonRpc(this.searchEndpoint, 'call', {
query: query
}).then(function (response) {
self._showLoading(false);
if (response.error) {
console.error('Search error:', response.error);
return;
}
self._renderResults(response.results, query);
}).catch(function (error) {
self._showLoading(false);
console.error('Search failed:', error);
});
},
_showLoading: function (show) {
var spinner = document.querySelector('.search-loading');
if (spinner) {
spinner.classList.toggle('active', show);
}
},
_renderResults: function (results, query) {
if (!this.resultsContainer) {
return;
}
if (!results || results.length === 0) {
this.resultsContainer.innerHTML = `
<tr>
<td colspan="6" class="text-center py-4">
<i class="fa fa-search fa-2x text-muted mb-2"></i>
<p class="text-muted mb-0">No results found for "${query}"</p>
</td>
</tr>
`;
return;
}
var html = '';
var isAuthorizer = window.location.pathname.includes('/my/authorizer');
var baseUrl = isAuthorizer ? '/my/authorizer/case/' : '/my/sales/case/';
results.forEach(function (order) {
var stateClass = 'bg-secondary';
if (order.state === 'sent') stateClass = 'bg-primary';
else if (order.state === 'sale') stateClass = 'bg-success';
html += `
<tr class="search-result-row">
<td>${self._highlightMatch(order.name, query)}</td>
<td>${self._highlightMatch(order.partner_name, query)}</td>
<td>${order.date_order}</td>
<td>${self._highlightMatch(order.claim_number || '-', query)}</td>
<td><span class="badge ${stateClass}">${order.state_display}</span></td>
<td>
<a href="${baseUrl}${order.id}" class="btn btn-sm btn-primary">
<i class="fa fa-eye me-1"></i>View
</a>
</td>
</tr>
`;
});
this.resultsContainer.innerHTML = html;
},
_highlightMatch: function (text, query) {
if (!text || !query) return text || '';
var regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
});
return publicWidget.registry.PortalSearch;
});

View File

@@ -0,0 +1,167 @@
/**
* Fusion Authorizer Portal - Signature Pad
* Touch-enabled digital signature capture
*/
odoo.define('fusion_authorizer_portal.signature_pad', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
var ajax = require('web.ajax');
// Signature Pad Class
var SignaturePad = function (canvas, options) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.options = Object.assign({
strokeColor: '#000000',
strokeWidth: 2,
backgroundColor: '#ffffff'
}, options || {});
this.isDrawing = false;
this.lastX = 0;
this.lastY = 0;
this.points = [];
this._initialize();
};
SignaturePad.prototype = {
_initialize: function () {
var self = this;
// Set canvas size
this._resizeCanvas();
// Set drawing style
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// Clear with background color
this.clear();
// Event listeners
this.canvas.addEventListener('mousedown', this._startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this._draw.bind(this));
this.canvas.addEventListener('mouseup', this._stopDrawing.bind(this));
this.canvas.addEventListener('mouseout', this._stopDrawing.bind(this));
// Touch events
this.canvas.addEventListener('touchstart', this._startDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchmove', this._draw.bind(this), { passive: false });
this.canvas.addEventListener('touchend', this._stopDrawing.bind(this), { passive: false });
this.canvas.addEventListener('touchcancel', this._stopDrawing.bind(this), { passive: false });
// Resize handler
window.addEventListener('resize', this._resizeCanvas.bind(this));
},
_resizeCanvas: function () {
var rect = this.canvas.getBoundingClientRect();
var ratio = window.devicePixelRatio || 1;
this.canvas.width = rect.width * ratio;
this.canvas.height = rect.height * ratio;
this.ctx.scale(ratio, ratio);
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
// Restore drawing style after resize
this.ctx.strokeStyle = this.options.strokeColor;
this.ctx.lineWidth = this.options.strokeWidth;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
},
_getPos: function (e) {
var rect = this.canvas.getBoundingClientRect();
var x, y;
if (e.touches && e.touches.length > 0) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.clientX - rect.left;
y = e.clientY - rect.top;
}
return { x: x, y: y };
},
_startDrawing: function (e) {
e.preventDefault();
this.isDrawing = true;
var pos = this._getPos(e);
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y, start: true });
},
_draw: function (e) {
e.preventDefault();
if (!this.isDrawing) return;
var pos = this._getPos(e);
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
this.ctx.lineTo(pos.x, pos.y);
this.ctx.stroke();
this.lastX = pos.x;
this.lastY = pos.y;
this.points.push({ x: pos.x, y: pos.y });
},
_stopDrawing: function (e) {
e.preventDefault();
this.isDrawing = false;
},
clear: function () {
this.ctx.fillStyle = this.options.backgroundColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.points = [];
},
isEmpty: function () {
return this.points.length === 0;
},
toDataURL: function (type, quality) {
return this.canvas.toDataURL(type || 'image/png', quality || 1.0);
}
};
// Make SignaturePad available globally for inline scripts
window.SignaturePad = SignaturePad;
// Widget for signature pads in portal
publicWidget.registry.SignaturePadWidget = publicWidget.Widget.extend({
selector: '.signature-pad-container',
start: function () {
this._super.apply(this, arguments);
var canvas = this.el.querySelector('canvas');
if (canvas) {
this.signaturePad = new SignaturePad(canvas);
}
return Promise.resolve();
},
getSignaturePad: function () {
return this.signaturePad;
}
});
return {
SignaturePad: SignaturePad,
Widget: publicWidget.registry.SignaturePadWidget
};
});

View File

@@ -0,0 +1,97 @@
/**
* Technician Location Logger
* Logs GPS location every 5 minutes during working hours (9 AM - 6 PM)
* Only logs while the browser tab is visible.
*/
(function () {
'use strict';
var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
var STORE_OPEN_HOUR = 9;
var STORE_CLOSE_HOUR = 18;
var locationTimer = null;
function isWorkingHours() {
var now = new Date();
var hour = now.getHours();
return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
}
function isTechnicianPortal() {
// Check if we're on a technician portal page
return window.location.pathname.indexOf('/my/technician') !== -1;
}
function logLocation() {
if (!isWorkingHours()) {
return;
}
if (document.hidden) {
return;
}
if (!navigator.geolocation) {
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
var data = {
jsonrpc: '2.0',
method: 'call',
params: {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || 0,
}
};
fetch('/my/technician/location/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(function () {
// Silently fail - location logging is best-effort
});
},
function () {
// Geolocation permission denied or error - silently ignore
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
function startLocationLogging() {
if (!isTechnicianPortal()) {
return;
}
// Log immediately on page load
logLocation();
// Set interval for periodic logging
locationTimer = setInterval(logLocation, INTERVAL_MS);
// Pause/resume on tab visibility change
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
// Tab hidden - clear interval to save battery
if (locationTimer) {
clearInterval(locationTimer);
locationTimer = null;
}
} else {
// Tab visible again - log immediately and restart interval
logLocation();
if (!locationTimer) {
locationTimer = setInterval(logLocation, INTERVAL_MS);
}
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startLocationLogging);
} else {
startLocationLogging();
}
})();

View File

@@ -0,0 +1,96 @@
/**
* Fusion Technician Portal - Push Notification Registration
* Registers service worker and subscribes to push notifications.
* Include this script on technician portal pages.
*/
(function() {
'use strict';
// Only run on technician portal pages
if (!document.querySelector('.tech-portal') && !window.location.pathname.startsWith('/my/technician')) {
return;
}
// Get VAPID public key from meta tag or page data
var vapidMeta = document.querySelector('meta[name="vapid-public-key"]');
var vapidPublicKey = vapidMeta ? vapidMeta.content : null;
if (!vapidPublicKey || !('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
function urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var rawData = window.atob(base64);
var outputArray = new Uint8Array(rawData.length);
for (var i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function registerPushSubscription() {
try {
// Register service worker
var registration = await navigator.serviceWorker.register(
'/fusion_authorizer_portal/static/src/js/technician_sw.js',
{scope: '/my/technician/'}
);
// Wait for service worker to be ready
await navigator.serviceWorker.ready;
// Check existing subscription
var subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Request permission
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('[TechPush] Notification permission denied');
return;
}
// Subscribe
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
// Send subscription to server
var key = subscription.getKey('p256dh');
var auth = subscription.getKey('auth');
var response = await fetch('/my/technician/push/subscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
endpoint: subscription.endpoint,
p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(key))),
auth: btoa(String.fromCharCode.apply(null, new Uint8Array(auth))),
}
}),
});
var data = await response.json();
if (data.result && data.result.success) {
console.log('[TechPush] Push subscription registered successfully');
}
} catch (error) {
console.warn('[TechPush] Push registration failed:', error);
}
}
// Register after page load
if (document.readyState === 'complete') {
registerPushSubscription();
} else {
window.addEventListener('load', registerPushSubscription);
}
})();

View File

@@ -0,0 +1,77 @@
/**
* Fusion Technician Portal - Service Worker for Push Notifications
* Handles push events and notification clicks.
*/
self.addEventListener('push', function(event) {
if (!event.data) return;
var data;
try {
data = event.data.json();
} catch (e) {
data = {title: 'New Notification', body: event.data.text()};
}
var options = {
body: data.body || '',
icon: '/fusion_authorizer_portal/static/description/icon.png',
badge: '/fusion_authorizer_portal/static/description/icon.png',
tag: 'tech-task-' + (data.task_id || 'general'),
renotify: true,
data: {
url: data.url || '/my/technician',
taskId: data.task_id,
taskType: data.task_type,
},
actions: [],
};
// Add contextual actions based on task type
if (data.url) {
options.actions.push({action: 'view', title: 'View Task'});
}
if (data.task_type === 'delivery' || data.task_type === 'repair') {
options.actions.push({action: 'navigate', title: 'Navigate'});
}
event.waitUntil(
self.registration.showNotification(data.title || 'Fusion Technician', options)
);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = '/my/technician';
if (event.notification.data && event.notification.data.url) {
url = event.notification.data.url;
}
if (event.action === 'navigate' && event.notification.data && event.notification.data.taskId) {
// Open Google Maps for the task (will redirect through portal)
url = '/my/technician/task/' + event.notification.data.taskId;
}
event.waitUntil(
clients.matchAll({type: 'window', includeUncontrolled: true}).then(function(clientList) {
// Focus existing window if open
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if (client.url.indexOf('/my/technician') !== -1 && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// Keep service worker alive
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Fusion Authorizer Portal - Message Authorizer Chatter Button
Copyright 2026 Nexa Systems Inc.
License OPL-1
Adds a "Message Authorizer" icon button to the chatter topbar
on sale.order forms (after the Activity button).
-->
<templates xml:space="preserve">
<t t-inherit="mail.Chatter" t-inherit-mode="extension">
<!-- Insert after the Activity button -->
<xpath expr="//button[hasclass('o-mail-Chatter-activity')]" position="after">
<button t-if="state.thread and state.thread.model === 'sale.order'"
class="o-mail-Chatter-messageAuthorizer btn btn-secondary text-nowrap me-1"
t-att-class="{ 'my-2': !props.compactHeight }"
t-on-click="onClickMessageAuthorizer"
title="Message Authorizer">
<span>Message Authorizer</span>
</button>
</xpath>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show More