Compare commits

...

33 Commits

Author SHA1 Message Date
f3766c2898 feat: add x_fc_authorizer_number, x_fc_account_number, x_marked_for fields; auto-link authorizer from XML
- fusion_claims: added x_fc_authorizer_number to res.partner for ADP authorizer registration numbers
- fusion_claims: XML parser auto-links authorizer contact to sale order by ADP number
- fusion_claims: removed size=9 constraint from x_fc_odsp_member_id
- fusion_claims: authorizer number shown on OT/PT contact form
- fusion_so_to_po: added x_marked_for (Many2one) field definition on purchase.order
- fusion_so_to_po: added x_fc_account_number on res.partner for vendor account numbers
2026-03-11 17:22:02 +00:00
431052920e feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views
- fusion_tasks: updated task views and map integration
- fusion_authorizer_portal: added page 11 signing, schedule booking, migrations
- fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator)
- fusion_ltc_management: new standalone LTC management module
2026-03-11 16:19:52 +00:00
1f79cdcaaf fix: improve AI chat table rendering with CSS styling and narrow-panel formatting
- Add SCSS for AI chat tables: borders, padding, zebra striping, hover, dark mode
- Style headings, code, bold text, and lists in chat messages
- Update system prompt: enforce 3-column max tables for narrow chat panel
- Use key-value (2-column) tables for summaries, split wide data into sections
- Provide explicit correct/wrong format examples in prompt
2026-03-10 02:54:04 +00:00
8761d0e7c7 feat: add Demographics & Analytics tool to Fusion Claims Intelligence
- Add Tool 6 for demographic analysis using direct SQL queries
- Age group breakdowns: clients, applications, avg apps/client, avg funding
- Device popularity by age bracket (under 45, 45-60, 61-75, 75+)
- City demographics with average age and funding per city
- Benefit type analysis (ODSP, OWP, ACSD, Regular)
- Top devices with average client age
- Overall funding summary (totals, averages, age range)
- Update AI topic and system prompt with Tool 6 routing examples
2026-03-10 02:45:51 +00:00
0053576cc2 fix: enable rich text markdown formatting for AI agent responses
- Install markdown2 dependency for Odoo AI module
- Update system prompt with explicit markdown formatting instructions
- Add example templates for client status and billing period responses
- Use tables, bold, headings, and code formatting for clean output
2026-03-10 02:39:02 +00:00
7bd7b8f7c4 fix: enhance Fusion Claims Intelligence AI with client status and billing period tools
- Fix _read_group override crash (dict_values not subscriptable) in sale_order.py
- Migrate _fc_tool_claims_stats from deprecated read_group() to _read_group() API
- Enrich client details tool with funding history, invoice status, prev-funded devices
- Add Client Status Lookup tool (search by name, returns orders/invoices/next steps)
- Add ADP Billing Period tool (invoiced amounts, paid/unpaid, submission deadlines)
- Update AI agent system prompt with all 5 tools and usage examples
2026-03-10 02:30:42 +00:00
3342b57469 feat: reorder search views - Customer first, add delivery/tags/status fields for ADP, ODSP, MOD 2026-03-10 01:46:15 +00:00
1bfa50aa5f feat: View Details uses ADP landscape report for ADP orders, add route decorators 2026-03-09 22:55:53 +00:00
85367747a6 fix: remove _get_display_grouped_section() call causing 500 error on portal 2026-03-09 22:49:55 +00:00
d7657bb356 feat: add borders, ADP Device Code, ADP/Client Portion columns and subtotals to portal view 2026-03-09 22:46:16 +00:00
9dac39853f fix: revert POD signature to original layout - only quotation reports need organized signature 2026-03-09 22:32:27 +00:00
c1a3b02ac5 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:46 +00:00
1f750a6db4 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:28 +00:00
ffcc83d7bd fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:03:12 +00:00
6c3c565440 fix: improve ADP report signature section with legal terms, date/time, printed name; switch portal to landscape report 2026-03-09 22:02:53 +00:00
1c191a54e1 fix: ADP portal sign/pay modal text - show client portion, not full total 2026-03-09 21:34:55 +00:00
512aedce69 fix: ADP portal - sidebar amount, claim details, signature report 2026-03-09 21:25:37 +00:00
f362fbd915 fix: ADP portal - sidebar amount, claim details, signature report 2026-03-09 21:25:05 +00:00
Nexa Agent
35399170b3 fix: ADP portal payment uses client portion instead of full order total
When customers pay for ADP quotations through the portal, the system
was charging the full order amount (ADP + client portions combined).
Now correctly charges only the client portion (25% for REG clients).

Changes:
- Override _get_prepayment_required_amount() to return client portion
- Override _has_to_be_paid() to skip payment for 100% ADP-funded orders
- Add portal controller to cap payment amount at client portion
- Add portal template showing ADP funding breakdown to customer
2026-03-09 21:11:19 +00:00
gsinghpal
3b3c57205a feat: add fusion_tasks module for field service management
Standalone module extracted from fusion_claims providing technician
scheduling, route simulation with Google Maps, GPS tracking, and
cross-instance task sync between odoo-westin and odoo-mobility.

Includes fix for route simulation: added Directions API error logging
to diagnose silent failures from conflicting Google Maps API keys.

Made-with: Cursor
2026-03-09 16:56:53 -04:00
gsinghpal
b649246e81 fix: auto-set rental sale type via onchange and context
is_rental_order is a computed field, not passed in vals during create.
Use in_rental_app context flag in create() and add onchange handler
so sale type sets to 'Rentals' immediately when toggled in the form.

Made-with: Cursor
2026-02-26 08:04:06 -05:00
gsinghpal
14fe9ab716 feat: hide authorizer for rental orders, auto-set sale type
Rental orders no longer show the "Authorizer Required?" question or
the Authorizer field. The sale type is automatically set to 'Rentals'
when creating or confirming a rental order. Validation logic also
skips authorizer checks for rental sale type.

Made-with: Cursor
2026-02-25 23:33:23 -05:00
gsinghpal
3c8f83b8e6 fix: remove invalid category_id from res.groups (not supported in Odoo 19)
Odoo 19 replaced category_id with privilege_id on res.groups.
Keep only privilege_id=False to clear it from the dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:55:17 -05:00
gsinghpal
4384987b82 fix: move Document Lock Override out of privilege dropdown
Rename to "Fusion: Document Lock Override" for clarity, clear
privilege_id so it appears under extra permissions instead of the
hierarchy dropdown, and add a descriptive tooltip explaining its
temporary nature and dependency on the settings toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:54:40 -05:00
gsinghpal
de8e3a83bb fix: explicitly clear privilege_id on portal groups to remove from dropdown
Setting privilege_id eval="False" forces Odoo to null out the existing
database value on upgrade. Simply omitting the field did not clear it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:45:53 -05:00
gsinghpal
3e59f9d5f6 fix: simplify fusion_claims permission dropdown and restrict settings access
Remove privilege_id from portal groups so they no longer appear in the
User settings dropdown (they are auto-assigned from Contact form).
Restrict Fusion Claims settings view to managers only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:41:20 -05:00
gsinghpal
34e5b46025 fix: comprehensive permission overhaul for fusion_faxes and fusion_ringcentral
Users without fax/RC groups could not open Sale Orders, Invoices, or
Contacts because the One2many computed fields triggered AccessError
on fusion.fax. Now base.group_user gets read-only access so computed
fields work silently, while all UI elements (smart buttons, header
buttons, menus, partner fields, settings) are restricted to the
proper security groups. Both modules now use Odoo 19 privilege
pattern for the user settings dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 11:52:06 -05:00
gsinghpal
e71bc503f9 changes 2026-02-25 09:40:41 -05:00
gsinghpal
0e1aebe60b feat: add Pending status for delivery/technician tasks
- New 'pending' status allows tasks to be created without a schedule,
  acting as a queue for unscheduled work that gets assigned later
- Pending group appears in the Delivery Map sidebar with amber color
- Other modules can create tasks in pending state for scheduling
- scheduled_date no longer required (null for pending tasks)
- New Pending Tasks menu item under Field Service
- Pending filter added to search view

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 04:21:05 -05:00
gsinghpal
84c009416e feat: add fusion_odoo_fixes module for default Odoo patches
- New standalone module to collect fixes for default Odoo behavior
- Fix #1: account_followup never clears followup_next_action_date
  when invoices are paid, causing collection emails to fully-paid
  clients. Hooks into _invoice_paid_hook to auto-clear stale data.
- Harden Fusion Accounting followup queries with amount_residual > 0
  filter and add balance check before sending emails

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 03:31:14 -05:00
gsinghpal
9d9453b5c8 feat: customizable portal gradient theme + LTC repair form fixes
- Add portal gradient branding settings with 4 presets (Green/Teal,
  Blue/Purple, Sunset Orange, Dark Slate) and custom color picker
- Live preview in settings, onchange updates colors reactively
- Dynamic gradient applied across portal home, CSS, and card elements
- Fix after photos visibility (conditional on resolved=yes)
- Fix technician section gating on portal repair form
- Move Create Sale Order button to form header for visibility
- Fix portal home row width inconsistency (xpath target change)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 02:53:55 -05:00
gsinghpal
f85658c03a updates 2026-02-24 01:18:44 -05:00
gsinghpal
e8e554de95 changes 2026-02-23 00:32:20 -05:00
914 changed files with 76899 additions and 126045 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
**/__pycache__/
*.pyc

View File

@@ -237,6 +237,7 @@ class FusionFollowupLine(models.Model):
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', today),
])
line.overdue_amount = sum(overdue_lines.mapped('amount_residual'))
@@ -281,6 +282,8 @@ class FusionFollowupLine(models.Model):
of the current follow-up level, then advances the partner to
the next level.
Skips sending if the partner no longer has any overdue balance.
:raises UserError: If no follow-up level is set.
"""
self.ensure_one()
@@ -291,6 +294,11 @@ class FusionFollowupLine(models.Model):
self.partner_id.display_name,
))
self._compute_overdue_values()
if self.overdue_amount <= 0:
self.followup_status = 'no_action_needed'
return True
level = self.followup_level_id
partner = self.partner_id

View File

@@ -76,7 +76,8 @@ class FusionPartnerFollowup(models.Model):
"""Return unpaid receivable move lines that are past due.
Searches for posted, unreconciled journal items on receivable
accounts where the maturity date is earlier than today.
accounts where the maturity date is earlier than today and
there is still an outstanding balance.
:returns: An ``account.move.line`` recordset.
"""
@@ -88,6 +89,7 @@ class FusionPartnerFollowup(models.Model):
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', today),
])

View File

@@ -188,6 +188,15 @@ class FusionFollowupSendWizard(models.TransientModel):
if not line:
raise UserError(_("No follow-up record is linked to this wizard."))
line._compute_overdue_values()
if line.overdue_amount <= 0:
line.followup_status = 'no_action_needed'
raise UserError(_(
"Partner '%s' no longer has any overdue balance. "
"Follow-up cancelled.",
line.partner_id.display_name,
))
partner = line.partner_id
# ---- Email ----

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_accounts_log_user,fusion.accounts.log user,model_fusion_accounts_log,group_fusion_accounts_user,1,0,0,0
access_fusion_accounts_log_manager,fusion.accounts.log manager,model_fusion_accounts_log,group_fusion_accounts_manager,1,1,1,1
1 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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
fusion_authorizer_portal/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -2,3 +2,29 @@
from . import models
from . import controllers
def _reactivate_views(env):
"""Ensure all module views are active after install/update.
Odoo silently deactivates inherited views when an xpath fails
validation (e.g. parent view structure changed between versions).
Once deactivated, subsequent -u runs never reactivate them.
This hook prevents that from silently breaking the portal.
"""
views = env['ir.ui.view'].sudo().search([
('key', 'like', 'fusion_authorizer_portal.%'),
('active', '=', False),
])
if views:
views.write({'active': True})
env.cr.execute("""
SELECT key FROM ir_ui_view
WHERE key LIKE 'fusion_authorizer_portal.%%'
AND id = ANY(%s)
""", [views.ids])
keys = [r[0] for r in env.cr.fetchall()]
import logging
logging.getLogger(__name__).warning(
"Reactivated %d deactivated views: %s", len(keys), keys
)

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.0.9',
'version': '19.0.2.5.0',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -50,8 +50,10 @@ This module provides external portal access for:
'website',
'mail',
'calendar',
'appointment',
'knowledge',
'fusion_claims',
'fusion_tasks',
],
'data': [
# Security
@@ -62,10 +64,12 @@ This module provides external portal access for:
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
'data/appointment_invite_data.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/loaner_checkout_views.xml',
'views/pdf_template_views.xml',
# Portal Templates
'views/portal_templates.xml',
@@ -75,6 +79,8 @@ This module provides external portal access for:
'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_schedule.xml',
'views/portal_page11_sign_templates.xml',
],
'assets': {
'web.assets_backend': [
@@ -91,9 +97,11 @@ This module provides external portal access for:
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js',
'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js',
],
},
'images': ['static/description/icon.png'],
'post_init_hook': '_reactivate_views',
'installable': True,
'application': False,
'auto_install': False,

View File

@@ -3,3 +3,5 @@
from . import portal_main
from . import portal_assessment
from . import pdf_editor
from . import portal_schedule
from . import portal_page11_sign

View File

@@ -35,6 +35,7 @@ class FusionPdfEditorController(http.Controller):
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', {
@@ -56,6 +57,7 @@ class FusionPdfEditorController(http.Controller):
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',
])
# ================================================================
@@ -73,6 +75,7 @@ class FusionPdfEditorController(http.Controller):
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:

View File

@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
posting_info = self._get_adp_posting_info()
response.qcontext.update(posting_info)
response.qcontext.update(self._get_clock_status_data())
# Add signature count (documents to sign) - only if Sign module is installed
sign_count = 0
@@ -38,6 +39,14 @@ class AuthorizerPortal(CustomerPortal):
response.qcontext['sign_count'] = sign_count
response.qcontext['sign_module_available'] = sign_module_available
ICP = request.env['ir.config_parameter'].sudo()
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
response.qcontext['portal_gradient'] = (
'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
)
return response
def _prepare_home_portal_values(self, counters):
@@ -716,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
'sale_type_filter': sale_type,
'status_filter': status,
}
values.update(self._get_clock_status_data())
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
@@ -1082,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error downloading proof of delivery: {e}")
return request.redirect('/my/funding-claims')
# ==================== CLOCK STATUS HELPER ====================
def _get_clock_status_data(self):
"""Get clock in/out status for the current portal user."""
try:
user = request.env.user
Employee = request.env['hr.employee'].sudo()
employee = Employee.search([('user_id', '=', user.id)], limit=1)
if not employee:
employee = Employee.search([
('name', '=', user.partner_id.name),
('user_id', '=', False),
], limit=1)
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
return {'clock_enabled': False}
is_checked_in = employee.attendance_state == 'checked_in'
check_in_time = ''
location_name = ''
if is_checked_in:
att = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_out', '=', False),
], limit=1)
if att:
check_in_time = att.check_in.isoformat() if att.check_in else ''
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
return {
'clock_enabled': True,
'clock_checked_in': is_checked_in,
'clock_check_in_time': check_in_time,
'clock_location_name': location_name,
}
except Exception as e:
_logger.warning("Clock status check failed: %s", e)
return {'clock_enabled': False}
# ==================== TECHNICIAN PORTAL ====================
def _check_technician_access(self):
"""Check if current user is a technician portal user."""
partner = request.env.user.partner_id
if not partner.is_technician_portal:
return False
if partner.is_technician_portal:
return True
has_tasks = request.env['fusion.technician.task'].sudo().search_count([
'|',
('technician_id', '=', request.env.user.id),
('additional_technician_ids', 'in', [request.env.user.id]),
], limit=1)
if has_tasks:
partner.sudo().write({'is_technician_portal': True})
return True
return False
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
def technician_dashboard(self, **kw):
@@ -1103,9 +1158,11 @@ class AuthorizerPortal(CustomerPortal):
SaleOrder = request.env['sale.order'].sudo()
today = fields.Date.context_today(request.env['fusion.technician.task'])
# Today's tasks
# Today's tasks (lead or additional technician)
today_tasks = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', today),
('status', '!=', 'cancelled'),
], order='sequence, time_start, id')
@@ -1135,7 +1192,9 @@ class AuthorizerPortal(CustomerPortal):
from datetime import timedelta
tomorrow = today + timedelta(days=1)
tomorrow_count = Task.search_count([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', tomorrow),
('status', '!=', 'cancelled'),
])
@@ -1147,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
clock_data = self._get_clock_status_data()
values = {
'today_tasks': today_tasks,
'current_task': current_task,
@@ -1162,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
'google_maps_api_key': google_maps_api_key,
'page_name': 'technician_dashboard',
}
values.update(clock_data)
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
@@ -1173,7 +1235,7 @@ class AuthorizerPortal(CustomerPortal):
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
domain = [('technician_id', '=', user.id)]
domain = ['|', ('technician_id', '=', user.id), ('additional_technician_ids', 'in', [user.id])]
if filter_status == 'scheduled':
domain.append(('status', '=', 'scheduled'))
@@ -1229,14 +1291,19 @@ class AuthorizerPortal(CustomerPortal):
try:
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
raise AccessError(_('You do not have access to this task.'))
except (AccessError, MissingError):
return request.redirect('/my/technician/tasks')
# Check for earlier uncompleted tasks (sequential enforcement)
earlier_incomplete = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', task.scheduled_date),
('time_start', '<', task.time_start),
('status', 'not in', ['completed', 'cancelled']),
@@ -1276,7 +1343,10 @@ class AuthorizerPortal(CustomerPortal):
Attachment = request.env['ir.attachment'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
from markupsafe import Markup, escape
@@ -1403,34 +1473,61 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
def technician_task_action(self, task_id, action, **kw):
"""Handle task status changes (start, complete, en_route, cancel)."""
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
"""Handle task status changes (start, complete, en_route, cancel).
Location is mandatory -- the client must send GPS coordinates."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found or not assigned to you'}
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
# Push location to remote instances for cross-instance visibility
try:
request.env['fusion.task.sync.config'].sudo()._push_technician_location(
user.id, latitude, longitude, accuracy or 0)
except Exception:
pass # Non-blocking: sync failure should not block task action
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
if action == 'en_route':
task.action_start_en_route()
task.with_context(**location_ctx).action_start_en_route()
elif action == 'start':
task.action_start_task()
task.with_context(**location_ctx).action_start_task()
elif action == 'complete':
completion_notes = kw.get('completion_notes', '')
if completion_notes:
task.completion_notes = completion_notes
task.action_complete_task()
task.with_context(**location_ctx).action_complete_task()
elif action == 'cancel':
task.action_cancel_task()
task.with_context(**location_ctx).action_cancel_task()
else:
return {'success': False, 'error': f'Unknown action: {action}'}
# For completion, also return next task info
result = {
'success': True,
'status': task.status,
@@ -1462,7 +1559,10 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
@@ -1524,7 +1624,10 @@ class AuthorizerPortal(CustomerPortal):
ICP = request.env['ir.config_parameter'].sudo()
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
@@ -1571,17 +1674,24 @@ class AuthorizerPortal(CustomerPortal):
return {'success': False, 'error': str(e)}
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
def technician_voice_complete(self, task_id, transcription, **kw):
def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
"""Format transcription with GPT and complete the task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
if not latitude or not longitude:
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return {'success': False, 'error': 'Invalid GPS coordinates.'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
ICP = request.env['ir.config_parameter'].sudo()
task = Task.browse(task_id)
if not task.exists() or task.technician_id.id != user.id:
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
@@ -1643,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal):
'completion_notes': completion_html,
'voice_note_transcription': transcription,
})
task.action_complete_task()
request.env['fusion.technician.location'].sudo().log_location(
latitude=latitude,
longitude=longitude,
accuracy=accuracy,
)
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
'action_accuracy': accuracy or 0,
}
task.with_context(**location_ctx).action_complete_task()
return {
'success': True,
@@ -1664,7 +1785,9 @@ class AuthorizerPortal(CustomerPortal):
tomorrow = today + timedelta(days=1)
tomorrow_tasks = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', tomorrow),
('status', '!=', 'cancelled'),
], order='sequence, time_start, id')
@@ -1703,7 +1826,9 @@ class AuthorizerPortal(CustomerPortal):
return request.redirect('/my/technician')
tasks = Task.search([
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
('scheduled_date', '=', schedule_date),
('status', '!=', 'cancelled'),
], order='sequence, time_start, id')
@@ -1752,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
_logger.warning(f"Location log error: {e}")
return {'success': False}
@http.route('/my/technician/clock-status', type='json', auth='user', website=True)
def technician_clock_status(self, **kw):
"""Check if the current technician is clocked in.
Returns {clocked_in: bool} so the JS background logger can decide
whether to track location. Replaces the fixed 9-6 hour window.
"""
if not self._check_technician_access():
return {'clocked_in': False}
try:
emp = request.env['hr.employee'].sudo().search([
('user_id', '=', request.env.user.id),
], limit=1)
if emp and emp.attendance_state == 'checked_in':
return {'clocked_in': True}
except Exception:
pass
return {'clocked_in': False}
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
def technician_save_start_location(self, address='', **kw):
"""Save the technician's personal start location."""
@@ -1827,7 +1971,9 @@ class AuthorizerPortal(CustomerPortal):
if not has_access and partner.is_technician_portal:
task_count = request.env['fusion.technician.task'].sudo().search_count([
('sale_order_id', '=', order.id),
'|',
('technician_id', '=', user.id),
('additional_technician_ids', 'in', [user.id]),
])
if task_count:
has_access = True
@@ -2017,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal):
_logger.error(f"Error saving POD signature: {e}")
return {'success': False, 'error': str(e)}
# ==================== TASK-LEVEL POD SIGNATURE ====================
@http.route('/my/technician/task/<int:task_id>/pod', type='http', auth='user', website=True)
def task_pod_signature_page(self, task_id, **kw):
"""Task-level POD signature capture page (works for all tasks including shadow)."""
if not self._check_technician_access():
return request.redirect('/my')
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
raise AccessError(_('You do not have access to this task.'))
except (AccessError, MissingError):
return request.redirect('/my/technician/tasks')
values = {
'task': task,
'has_existing_signature': bool(task.pod_signature),
'page_name': 'task_pod_signature',
}
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
"""Save POD signature directly on a task."""
if not self._check_technician_access():
return {'success': False, 'error': 'Access denied'}
user = request.env.user
Task = request.env['fusion.technician.task'].sudo()
try:
task = Task.browse(task_id)
if not task.exists() or (
task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids
):
return {'success': False, 'error': 'Task not found'}
if not client_name or not client_name.strip():
return {'success': False, 'error': 'Client name is required'}
if not signature_data:
return {'success': False, 'error': 'Signature is required'}
if ',' in signature_data:
signature_data = signature_data.split(',')[1]
from datetime import datetime as dt_datetime
sig_date = None
if signature_date:
try:
sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date()
except ValueError:
pass
task.write({
'pod_signature': signature_data,
'pod_client_name': client_name.strip(),
'pod_signature_date': sig_date,
'pod_signed_by_user_id': user.id,
'pod_signed_datetime': fields.Datetime.now(),
})
if task.sale_order_id:
task.sale_order_id.write({
'x_fc_pod_signature': signature_data,
'x_fc_pod_client_name': client_name.strip(),
'x_fc_pod_signature_date': sig_date,
'x_fc_pod_signed_by_user_id': user.id,
'x_fc_pod_signed_datetime': fields.Datetime.now(),
})
return {
'success': True,
'message': 'Signature saved successfully',
'redirect_url': f'/my/technician/task/{task_id}',
}
except Exception as e:
_logger.error(f"Error saving task POD signature: {e}")
return {'success': False, 'error': str(e)}
def _generate_signed_pod_pdf(self, order, save_to_field=True):
"""Generate a signed POD PDF with the signature embedded.
@@ -2466,3 +2700,71 @@ class AuthorizerPortal(CustomerPortal):
_logger.info(f"Attached video to assessment {assessment.reference}")
except Exception as e:
_logger.warning(f"Failed to attach video to assessment {assessment.reference}: {e}")
# =================================================================
# RENTAL PICKUP INSPECTION (added by fusion_rental)
# =================================================================
@http.route(
'/my/technician/rental-inspection/<int:task_id>',
type='http', auth='user', website=True,
)
def rental_inspection_page(self, task_id, **kw):
"""Render the rental pickup inspection form for the technician."""
user = request.env.user
task = request.env['fusion.technician.task'].sudo().browse(task_id)
if (
not task.exists()
or (task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids)
or task.task_type != 'pickup'
):
return request.redirect('/my')
return request.render(
'fusion_rental.portal_rental_inspection',
{
'task': task,
'order': task.sale_order_id,
'page_name': 'rental_inspection',
},
)
@http.route(
'/my/technician/rental-inspection/<int:task_id>/submit',
type='json', auth='user', methods=['POST'],
)
def rental_inspection_submit(self, task_id, **kwargs):
"""Save the rental inspection results."""
user = request.env.user
task = request.env['fusion.technician.task'].sudo().browse(task_id)
if (
not task.exists()
or (task.technician_id.id != user.id
and user.id not in task.additional_technician_ids.ids)
or task.task_type != 'pickup'
):
return {'success': False, 'error': 'Access denied.'}
condition = kwargs.get('condition', '')
notes = kwargs.get('notes', '')
photo_ids = kwargs.get('photo_ids', [])
if not condition:
return {'success': False, 'error': 'Please select a condition.'}
vals = {
'rental_inspection_condition': condition,
'rental_inspection_notes': notes,
'rental_inspection_completed': True,
}
if photo_ids:
vals['rental_inspection_photo_ids'] = [(6, 0, photo_ids)]
task.write(vals)
return {
'success': True,
'message': 'Inspection saved. You can now complete the task.',
}

View File

@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
from odoo import http, fields, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class Page11PublicSignController(http.Controller):
def _get_sign_request(self, token):
"""Look up and validate a signing request by token."""
req = request.env['fusion.page11.sign.request'].sudo().search([
('access_token', '=', token),
], limit=1)
if not req:
return None, 'not_found'
if req.state == 'signed':
return req, 'already_signed'
if req.state == 'cancelled':
return req, 'cancelled'
if req.state == 'expired' or (
req.expiry_date and req.expiry_date < fields.Datetime.now()
):
if req.state != 'expired':
req.state = 'expired'
return req, 'expired'
return req, 'ok'
@http.route('/page11/sign/<string:token>', type='http', auth='public',
website=True, sitemap=False)
def page11_sign_form(self, token, **kw):
"""Display the Page 11 signing form."""
sign_req, status = self._get_sign_request(token)
if status == 'not_found':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_invalid', {}
)
if status in ('expired', 'cancelled'):
return request.render(
'fusion_authorizer_portal.portal_page11_sign_expired',
{'sign_request': sign_req},
)
if status == 'already_signed':
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)
order = sign_req.sale_order_id
partner = order.partner_id
assessment = request.env['fusion.assessment'].sudo().search([
('sale_order_id', '=', order.id),
], limit=1, order='create_date desc')
ICP = request.env['ir.config_parameter'].sudo()
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
client_first_name = ''
client_last_name = ''
client_middle_name = ''
client_health_card = ''
client_health_card_version = ''
if assessment:
client_first_name = assessment.client_first_name or ''
client_last_name = assessment.client_last_name or ''
client_middle_name = assessment.client_middle_name or ''
client_health_card = assessment.client_health_card or ''
client_health_card_version = assessment.client_health_card_version or ''
else:
first, last = order._get_client_name_parts()
client_first_name = first
client_last_name = last
values = {
'sign_request': sign_req,
'order': order,
'partner': partner,
'assessment': assessment,
'company': order.company_id,
'token': token,
'signer_type': sign_req.signer_type,
'is_agent': sign_req.signer_type != 'client',
'google_maps_api_key': google_maps_api_key,
'client_first_name': client_first_name,
'client_last_name': client_last_name,
'client_middle_name': client_middle_name,
'client_health_card': client_health_card,
'client_health_card_version': client_health_card_version,
}
return request.render(
'fusion_authorizer_portal.portal_page11_public_sign', values,
)
@http.route('/page11/sign/<string:token>/submit', type='http',
auth='public', methods=['POST'], website=True,
csrf=True, sitemap=False)
def page11_sign_submit(self, token, **post):
"""Process the submitted Page 11 signature."""
sign_req, status = self._get_sign_request(token)
if status != 'ok':
return request.redirect(f'/page11/sign/{token}')
signature_data = post.get('signature_data', '')
if not signature_data:
return request.redirect(f'/page11/sign/{token}?error=no_signature')
if signature_data.startswith('data:image'):
signature_data = signature_data.split(',', 1)[1]
consent_accepted = post.get('consent_declaration', '') == 'on'
if not consent_accepted:
return request.redirect(f'/page11/sign/{token}?error=no_consent')
signer_name = post.get('signer_name', sign_req.signer_name or '')
chosen_signer_type = post.get('signer_type', sign_req.signer_type or 'client')
consent_signed_by = 'applicant' if chosen_signer_type == 'client' else 'agent'
signer_type_labels = {
'spouse': 'Spouse', 'parent': 'Parent',
'legal_guardian': 'Legal Guardian',
'poa': 'Power of Attorney',
'public_trustee': 'Public Trustee',
}
vals = {
'signature_data': signature_data,
'signer_name': signer_name,
'signer_type': chosen_signer_type,
'consent_declaration_accepted': True,
'consent_signed_by': consent_signed_by,
'signed_date': fields.Datetime.now(),
'state': 'signed',
'client_first_name': post.get('client_first_name', ''),
'client_last_name': post.get('client_last_name', ''),
'client_health_card': post.get('client_health_card', ''),
'client_health_card_version': post.get('client_health_card_version', ''),
}
if consent_signed_by == 'agent':
vals.update({
'agent_first_name': post.get('agent_first_name', ''),
'agent_last_name': post.get('agent_last_name', ''),
'agent_middle_initial': post.get('agent_middle_initial', ''),
'agent_phone': post.get('agent_phone', ''),
'agent_unit': post.get('agent_unit', ''),
'agent_street_number': post.get('agent_street_number', ''),
'agent_street': post.get('agent_street', ''),
'agent_city': post.get('agent_city', ''),
'agent_province': post.get('agent_province', 'Ontario'),
'agent_postal_code': post.get('agent_postal_code', ''),
'signer_relationship': signer_type_labels.get(chosen_signer_type, chosen_signer_type),
})
sign_req.sudo().write(vals)
try:
sign_req.sudo()._generate_signed_pdf()
except Exception as e:
_logger.error("PDF generation failed for sign request %s: %s", sign_req.id, e)
try:
sign_req.sudo()._update_sale_order()
except Exception as e:
_logger.error("Sale order update failed for sign request %s: %s", sign_req.id, e)
return request.render(
'fusion_authorizer_portal.portal_page11_sign_success',
{'sign_request': sign_req, 'token': token},
)
@http.route('/page11/sign/<string:token>/download', type='http',
auth='public', website=True, sitemap=False)
def page11_download_pdf(self, token, **kw):
"""Download the signed Page 11 PDF."""
sign_req = request.env['fusion.page11.sign.request'].sudo().search([
('access_token', '=', token),
('state', '=', 'signed'),
], limit=1)
if not sign_req or not sign_req.signed_pdf:
return request.redirect(f'/page11/sign/{token}')
pdf_content = base64.b64decode(sign_req.signed_pdf)
filename = sign_req.signed_pdf_filename or 'Page11_Signed.pdf'
return request.make_response(
pdf_content,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'attachment; filename="{filename}"'),
('Content-Length', str(len(pdf_content))),
],
)

View File

@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
from odoo import http, _, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, ValidationError
from datetime import datetime, timedelta
import json
import logging
import pytz
_logger = logging.getLogger(__name__)
class PortalSchedule(CustomerPortal):
"""Portal controller for appointment scheduling and calendar management."""
def _get_schedule_values(self):
"""Common values for schedule pages."""
ICP = request.env['ir.config_parameter'].sudo()
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
return {
'portal_gradient': gradient,
'google_maps_api_key': google_maps_api_key,
}
def _get_user_timezone(self):
tz_name = request.env.user.tz or 'America/Toronto'
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
return pytz.timezone('America/Toronto')
def _get_appointment_types(self):
"""Get appointment types available to the current user."""
return request.env['appointment.type'].sudo().search([
('staff_user_ids', 'in', [request.env.user.id]),
])
@http.route(['/my/schedule'], type='http', auth='user', website=True)
def schedule_page(self, **kw):
"""Schedule overview: upcoming appointments and shareable link."""
partner = request.env.user.partner_id
user = request.env.user
now = fields.Datetime.now()
upcoming_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now),
], order='start asc', limit=20)
today_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now.replace(hour=0, minute=0, second=0)),
('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)),
], order='start asc')
invite = request.env['appointment.invite'].sudo().search([
('staff_user_ids', 'in', [user.id]),
], limit=1)
share_url = invite.book_url if invite else ''
appointment_types = self._get_appointment_types()
tz = self._get_user_timezone()
values = self._get_schedule_values()
values.update({
'page_name': 'schedule',
'upcoming_events': upcoming_events,
'today_events': today_events,
'share_url': share_url,
'appointment_types': appointment_types,
'user_tz': tz,
'now': now,
})
return request.render('fusion_authorizer_portal.portal_schedule_page', values)
@http.route(['/my/schedule/book'], type='http', auth='user', website=True)
def schedule_book(self, appointment_type_id=None, **kw):
"""Booking form for a new appointment."""
appointment_types = self._get_appointment_types()
if not appointment_types:
return request.redirect('/my/schedule')
if appointment_type_id:
selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not selected_type.exists():
selected_type = appointment_types[0]
else:
selected_type = appointment_types[0]
values = self._get_schedule_values()
values.update({
'page_name': 'schedule_book',
'appointment_types': appointment_types,
'selected_type': selected_type,
'now': fields.Datetime.now(),
'error': kw.get('error'),
'success': kw.get('success'),
})
return request.render('fusion_authorizer_portal.portal_schedule_book', values)
@http.route('/my/schedule/available-slots', type='json', auth='user', website=True)
def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw):
"""JSON-RPC endpoint: return available time slots for a date."""
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not appointment_type.exists():
return {'error': 'Appointment type not found', 'slots': []}
user = request.env.user
tz_name = user.tz or 'America/Toronto'
tz = self._get_user_timezone()
ref_date = fields.Datetime.now()
slot_data = appointment_type._get_appointment_slots(
timezone=tz_name,
filter_users=request.env['res.users'].sudo().browse(user.id),
asked_capacity=1,
reference_date=ref_date,
)
filtered_slots = []
target_date = None
if selected_date:
try:
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
except ValueError:
return {'error': 'Invalid date format', 'slots': []}
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if target_date and day != target_date:
continue
for slot in day_info.get('slots', []):
slot_dt_str = slot.get('datetime')
if not slot_dt_str:
continue
filtered_slots.append({
'datetime': slot_dt_str,
'start_hour': slot.get('start_hour', ''),
'end_hour': slot.get('end_hour', ''),
'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)),
'staff_user_id': slot.get('staff_user_id', user.id),
})
available_dates = []
if not target_date:
seen = set()
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if day and day_info.get('slots') and str(day) not in seen:
seen.add(str(day))
available_dates.append(str(day))
return {
'slots': filtered_slots,
'available_dates': sorted(available_dates),
'duration': appointment_type.appointment_duration,
'timezone': tz_name,
}
@http.route('/my/schedule/week-events', type='json', auth='user', website=True)
def schedule_week_events(self, selected_date, **kw):
"""Return the user's calendar events for the Mon-Sun week containing selected_date."""
try:
target = datetime.strptime(selected_date, '%Y-%m-%d').date()
except (ValueError, TypeError):
return {'error': 'Invalid date format', 'events': [], 'week_days': []}
monday = target - timedelta(days=target.weekday())
sunday = monday + timedelta(days=6)
partner = request.env.user.partner_id
tz = self._get_user_timezone()
monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)
events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', monday_start_utc),
('start', '<=', sunday_end_utc),
], order='start asc')
event_list = []
for ev in events:
start_utc = ev.start
stop_utc = ev.stop
start_local = pytz.utc.localize(start_utc).astimezone(tz)
stop_local = pytz.utc.localize(stop_utc).astimezone(tz)
event_list.append({
'name': ev.name or '',
'start': start_local.strftime('%Y-%m-%d %H:%M'),
'end': stop_local.strftime('%Y-%m-%d %H:%M'),
'start_time': start_local.strftime('%I:%M %p'),
'end_time': stop_local.strftime('%I:%M %p'),
'day_of_week': start_local.weekday(),
'date': start_local.strftime('%Y-%m-%d'),
'location': ev.location or '',
'duration': ev.duration,
})
day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
week_days = []
for i in range(7):
day = monday + timedelta(days=i)
week_days.append({
'label': day_labels[i],
'date': day.strftime('%Y-%m-%d'),
'day_num': day.day,
'is_selected': day == target,
})
return {
'events': event_list,
'week_days': week_days,
'selected_date': selected_date,
}
@http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
def schedule_book_submit(self, **post):
"""Process the booking form submission."""
appointment_type_id = int(post.get('appointment_type_id', 0))
appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
if not appointment_type.exists():
return request.redirect('/my/schedule/book?error=Invalid+appointment+type')
client_name = (post.get('client_name') or '').strip()
client_street = (post.get('client_street') or '').strip()
client_city = (post.get('client_city') or '').strip()
client_province = (post.get('client_province') or '').strip()
client_postal = (post.get('client_postal') or '').strip()
notes = (post.get('notes') or '').strip()
slot_datetime = (post.get('slot_datetime') or '').strip()
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
if not client_name or not slot_datetime:
return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')
user = request.env.user
tz = self._get_user_timezone()
try:
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/my/schedule/book?error=Invalid+time+slot')
duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
is_valid = appointment_type._check_appointment_is_valid_slot(
staff_user=user,
resources=request.env['appointment.resource'],
asked_capacity=1,
timezone=str(tz),
start_dt=start_dt_utc,
duration=duration,
allday=False,
)
if not is_valid:
return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')
address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
location = ', '.join(address_parts)
description_lines = []
if client_name:
description_lines.append(f"Client: {client_name}")
if location:
description_lines.append(f"Address: {location}")
if notes:
description_lines.append(f"Notes: {notes}")
description = '\n'.join(description_lines)
event_name = f"{client_name} - {appointment_type.name}"
booking_line_values = [{
'appointment_user_id': user.id,
'capacity_reserved': 1,
'capacity_used': 1,
}]
try:
event_vals = appointment_type._prepare_calendar_event_values(
asked_capacity=1,
booking_line_values=booking_line_values,
description=description,
duration=duration,
allday=False,
appointment_invite=request.env['appointment.invite'],
guests=request.env['res.partner'],
name=event_name,
customer=user.partner_id,
staff_user=user,
start=start_dt_utc,
stop=stop_dt_utc,
)
event_vals['location'] = location
event = request.env['calendar.event'].sudo().create(event_vals)
_logger.info(
"Appointment booked: %s at %s (event ID: %s)",
event_name, start_dt_utc, event.id,
)
except Exception as e:
_logger.error("Failed to create appointment: %s", e)
return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')
return request.redirect('/my/schedule?success=Appointment+booked+successfully')

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Auto-create a shareable booking link for staff members.
URL: /book/book-appointment
Filtered to appointment type "Assessment" and staff users configured on that type. -->
<record id="default_appointment_invite" model="appointment.invite">
<field name="short_code">book-appointment</field>
<field name="appointment_type_ids" eval="[(6, 0, [])]"/>
</record>
</odoo>

View File

@@ -1,721 +0,0 @@
# Fusion Authorizer & Sales Portal
**Version:** 19.0.1.0.0
**License:** LGPL-3
**Category:** Sales/Portal
**Author:** Fusion Claims
## Table of Contents
1. [Overview](#overview)
2. [Features](#features)
3. [Installation](#installation)
4. [Configuration](#configuration)
5. [Models](#models)
6. [Controllers & Routes](#controllers--routes)
7. [Security](#security)
8. [Frontend Assets](#frontend-assets)
9. [Email Templates](#email-templates)
10. [User Guide](#user-guide)
11. [API Reference](#api-reference)
12. [Troubleshooting](#troubleshooting)
13. [Changelog](#changelog)
---
## Overview
The **Fusion Authorizer & Sales Portal** module extends Odoo's portal functionality to provide external access for two key user types:
- **Authorizers (Occupational Therapists/OTs):** Healthcare professionals who authorize ADP (Assistive Devices Program) claims
- **Sales Representatives:** Field sales staff who conduct client assessments and manage orders
This module integrates with the `fusion_claims` module to provide a seamless workflow for ADP claims management, from initial client assessment through to order completion.
### Target Platform
- **Odoo Enterprise v19**
- Requires: `base`, `sale`, `portal`, `website`, `mail`, `fusion_claims`
---
## Features
### Authorizer Portal
- View all assigned ADP cases with full details (excluding internal costs)
- Real-time search by client name, reference numbers, or claim number
- Upload ADP application documents with revision tracking
- Add comments/notes to cases
- Download submitted ADP applications
- Receive email notifications for new assignments and status changes
### Sales Rep Portal
- View sales cases linked to the logged-in user
- Start and manage client assessments
- Record detailed wheelchair specifications and measurements
- Capture digital signatures for ADP pages 11 & 12
- Track assessment progress through workflow states
### Assessment System
- Comprehensive client information collection
- Wheelchair specifications (seat width, depth, height, cushion type, etc.)
- Accessibility and mobility needs documentation
- Touch-friendly digital signature capture
- Automatic draft Sale Order creation upon completion
- Document distribution to authorizers, sales reps, and internal records
- Automated email notifications
---
## Installation
### Prerequisites
1. Odoo Enterprise v19 installed and running
2. The `fusion_claims` module installed and configured
3. Portal module enabled
4. Website module enabled
5. Mail module configured with outgoing email server
### Installation Steps
1. **Copy the module** to your Odoo addons directory:
```bash
cp -r fusion_authorizer_portal /path/to/odoo/custom-addons/
```
2. **Update the apps list** in Odoo:
- Go to Apps menu
- Click "Update Apps List"
- Search for "Fusion Authorizer"
3. **Install the module**:
- Click Install on "Fusion Authorizer & Sales Portal"
- Wait for installation to complete
4. **Restart Odoo** (recommended):
```bash
docker restart odoo-app # For Docker installations
# OR
sudo systemctl restart odoo # For systemd installations
```
---
## Configuration
### Granting Portal Access to Users
1. Navigate to **Contacts** in Odoo backend
2. Open the contact record for the authorizer or sales rep
3. Go to the **Portal Access** tab
4. Check the appropriate role:
- `Is Authorizer` - For Occupational Therapists
- `Is Sales Rep (Portal)` - For Sales Representatives
5. Click the **Grant Portal Access** button
6. An invitation email will be sent to the contact's email address
### Setting Up Authorizers on Sales Orders
1. Open a Sales Order
2. In the order details, set the **Authorizer** field (`x_fc_authorizer_id`)
3. The authorizer will receive an email notification about the assignment
4. The case will appear in their portal dashboard
---
## Models
### New Models
#### `fusion.assessment`
**Wheelchair Assessment Record**
Captures comprehensive client assessment data including:
| Field Group | Fields |
|-------------|--------|
| **Client Info** | `client_name`, `client_first_name`, `client_last_name`, `client_street`, `client_city`, `client_state`, `client_postal_code`, `client_country_id`, `client_phone`, `client_mobile`, `client_email`, `client_dob`, `client_health_card`, `client_reference_1`, `client_reference_2` |
| **Participants** | `sales_rep_id` (res.users), `authorizer_id` (res.partner) |
| **Assessment Details** | `assessment_date`, `assessment_location`, `assessment_location_notes` |
| **Measurements** | `seat_width`, `seat_depth`, `seat_to_floor_height`, `back_height`, `armrest_height`, `footrest_length`, `overall_width`, `overall_length`, `overall_height`, `seat_angle`, `back_angle`, `client_weight`, `client_height` |
| **Product Types** | `cushion_type`, `cushion_notes`, `backrest_type`, `backrest_notes`, `frame_type`, `frame_notes`, `wheel_type`, `wheel_notes` |
| **Needs** | `mobility_notes`, `accessibility_notes`, `special_requirements`, `diagnosis` |
| **Signatures** | `signature_page_11`, `signature_page_11_name`, `signature_page_11_date`, `signature_page_12`, `signature_page_12_name`, `signature_page_12_date` |
| **Status** | `state` (draft, pending_signature, completed, cancelled) |
| **References** | `reference` (auto-generated ASM-XXXXX), `sale_order_id`, `partner_id` |
**Key Methods:**
- `action_complete()` - Completes assessment, creates draft Sale Order, sends notifications
- `_ensure_partner()` - Creates or links res.partner for the client
- `_create_draft_sale_order()` - Generates Sale Order with specifications
- `_generate_signed_documents()` - Creates document records for signatures
- `_send_completion_notifications()` - Sends emails to authorizer and client
---
#### `fusion.adp.document`
**ADP Document Management with Revision Tracking**
| Field | Type | Description |
|-------|------|-------------|
| `sale_order_id` | Many2one | Link to Sale Order |
| `assessment_id` | Many2one | Link to Assessment |
| `document_type` | Selection | full_application, page_11, page_12, pages_11_12, final_submission, other |
| `file` | Binary | Document file content |
| `filename` | Char | Original filename |
| `file_size` | Integer | File size in bytes |
| `mimetype` | Char | MIME type |
| `revision` | Integer | Revision number (auto-incremented) |
| `revision_note` | Text | Notes about this revision |
| `is_current` | Boolean | Whether this is the current version |
| `uploaded_by` | Many2one | User who uploaded |
| `upload_date` | Datetime | Upload timestamp |
| `source` | Selection | portal, internal, assessment |
**Key Methods:**
- `action_download()` - Download the document
- `get_documents_for_order()` - Get all documents for a sale order
- `get_revision_history()` - Get all revisions of a document type
---
#### `fusion.authorizer.comment`
**Portal Comments System**
| Field | Type | Description |
|-------|------|-------------|
| `sale_order_id` | Many2one | Link to Sale Order |
| `assessment_id` | Many2one | Link to Assessment |
| `author_id` | Many2one | res.partner who authored |
| `author_user_id` | Many2one | res.users who authored |
| `comment` | Text | Comment content |
| `comment_type` | Selection | general, question, update, approval |
| `is_internal` | Boolean | Internal-only comment |
---
### Extended Models
#### `res.partner` (Extended)
| New Field | Type | Description |
|-----------|------|-------------|
| `is_authorizer` | Boolean | Partner is an Authorizer/OT |
| `is_sales_rep_portal` | Boolean | Partner is a Sales Rep with portal access |
| `authorizer_portal_user_id` | Many2one | Linked portal user account |
| `assigned_case_count` | Integer | Computed count of assigned cases |
| `assessment_count` | Integer | Computed count of assessments |
**New Methods:**
- `action_grant_portal_access()` - Creates portal user and sends invitation
- `action_view_assigned_cases()` - Opens list of assigned Sale Orders
- `action_view_assessments()` - Opens list of assessments
---
#### `sale.order` (Extended)
| New Field | Type | Description |
|-----------|------|-------------|
| `portal_comment_ids` | One2many | Comments from portal users |
| `portal_comment_count` | Integer | Computed comment count |
| `portal_document_ids` | One2many | Documents uploaded via portal |
| `portal_document_count` | Integer | Computed document count |
| `assessment_id` | Many2one | Source assessment that created this order |
| `portal_authorizer_id` | Many2one | Authorizer reference (computed from x_fc_authorizer_id) |
**New Methods:**
- `_send_authorizer_assignment_notification()` - Email on authorizer assignment
- `_send_status_change_notification()` - Email on status change
- `get_portal_display_data()` - Safe data for portal display (excludes costs)
- `get_authorizer_portal_cases()` - Search cases for authorizer portal
- `get_sales_rep_portal_cases()` - Search cases for sales rep portal
---
## Controllers & Routes
### Authorizer Portal Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/my/authorizer` | GET | user | Authorizer dashboard |
| `/my/authorizer/cases` | GET | user | List of assigned cases |
| `/my/authorizer/cases/search` | POST | user | AJAX search (jsonrpc) |
| `/my/authorizer/case/<id>` | GET | user | Case detail view |
| `/my/authorizer/case/<id>/comment` | POST | user | Add comment to case |
| `/my/authorizer/case/<id>/upload` | POST | user | Upload document |
| `/my/authorizer/document/<id>/download` | GET | user | Download document |
### Sales Rep Portal Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/my/sales` | GET | user | Sales rep dashboard |
| `/my/sales/cases` | GET | user | List of sales cases |
| `/my/sales/cases/search` | POST | user | AJAX search (jsonrpc) |
| `/my/sales/case/<id>` | GET | user | Case detail view |
### Assessment Routes
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/my/assessments` | GET | user | List of assessments |
| `/my/assessment/new` | GET | user | New assessment form |
| `/my/assessment/<id>` | GET | user | View/edit assessment |
| `/my/assessment/save` | POST | user | Save assessment data |
| `/my/assessment/<id>/signatures` | GET | user | Signature capture page |
| `/my/assessment/<id>/save_signature` | POST | user | Save signature (jsonrpc) |
| `/my/assessment/<id>/complete` | POST | user | Complete assessment |
---
## Security
### Security Groups
| Group | XML ID | Description |
|-------|--------|-------------|
| Authorizer Portal | `group_authorizer_portal` | Access to authorizer portal features |
| Sales Rep Portal | `group_sales_rep_portal` | Access to sales rep portal features |
### Record Rules
| Model | Rule | Description |
|-------|------|-------------|
| `fusion.authorizer.comment` | Portal Read | Users can read non-internal comments on their cases |
| `fusion.authorizer.comment` | Portal Create | Users can create comments on their cases |
| `fusion.adp.document` | Portal Read | Users can read documents on their cases |
| `fusion.adp.document` | Portal Create | Users can upload documents to their cases |
| `fusion.assessment` | Portal Access | Users can access assessments they're linked to |
| `sale.order` | Portal Authorizer | Authorizers can view their assigned orders |
### Access Rights (ir.model.access.csv)
| Model | Group | Read | Write | Create | Unlink |
|-------|-------|------|-------|--------|--------|
| `fusion.authorizer.comment` | base.group_user | 1 | 1 | 1 | 1 |
| `fusion.authorizer.comment` | base.group_portal | 1 | 0 | 1 | 0 |
| `fusion.adp.document` | base.group_user | 1 | 1 | 1 | 1 |
| `fusion.adp.document` | base.group_portal | 1 | 0 | 1 | 0 |
| `fusion.assessment` | base.group_user | 1 | 1 | 1 | 1 |
| `fusion.assessment` | base.group_portal | 1 | 1 | 1 | 0 |
---
## Frontend Assets
### CSS (`static/src/css/portal_style.css`)
Custom portal styling with a dark blue and green color scheme:
- **Primary Color:** Dark blue (#1e3a5f)
- **Secondary Color:** Medium blue (#2c5282)
- **Accent Color:** Green (#38a169)
- **Background:** Light gray (#f7fafc)
Styled components:
- Portal cards with shadow effects
- Status badges with color coding
- Custom buttons with hover effects
- Responsive tables
- Form inputs with focus states
### JavaScript
#### `portal_search.js`
Real-time search functionality:
- Debounced input handling (300ms delay)
- AJAX calls to search endpoints
- Dynamic table updates
- Search result highlighting
#### `assessment_form.js`
Assessment form enhancements:
- Unsaved changes warning
- Auto-fill client name from first/last name
- Number input validation
- Form state tracking
#### `signature_pad.js`
Digital signature capture:
- HTML5 Canvas-based drawing
- Touch and mouse event support
- Clear signature functionality
- Export to base64 PNG
- AJAX save to server
---
## Email Templates
### Case Assignment (`mail_template_case_assigned`)
**Trigger:** Authorizer assigned to a Sale Order
**Recipient:** Authorizer email
**Content:** Case details, client information, link to portal
### Status Change (`mail_template_status_changed`)
**Trigger:** Sale Order state changes
**Recipient:** Assigned authorizer
**Content:** Previous and new status, case details
### Assessment Complete - Authorizer (`mail_template_assessment_complete_authorizer`)
**Trigger:** Assessment completed
**Recipient:** Assigned authorizer
**Content:** Assessment details, measurements, signed documents
### Assessment Complete - Client (`mail_template_assessment_complete_client`)
**Trigger:** Assessment completed
**Recipient:** Client email
**Content:** Confirmation, next steps, measurements summary
### Document Uploaded (`mail_template_document_uploaded`)
**Trigger:** Document uploaded via portal
**Recipient:** Internal team
**Content:** Document details, revision info, download link
---
## User Guide
### For Administrators
#### Granting Portal Access
1. Go to **Contacts** > Select the contact
2. Navigate to the **Portal Access** tab
3. Enable the appropriate role:
- Check `Is Authorizer` for OTs/Therapists
- Check `Is Sales Rep (Portal)` for Sales Reps
4. Click **Grant Portal Access**
5. The user receives an email with login instructions
#### Assigning Cases to Authorizers
1. Open a **Sale Order**
2. Set the **Authorizer** field to the appropriate contact
3. Save the order
4. The authorizer receives a notification email
5. The case appears in their portal dashboard
---
### For Authorizers
#### Accessing the Portal
1. Visit `https://your-domain.com/my`
2. Log in with your portal credentials
3. Click **Authorizer Portal** in the menu
#### Viewing Cases
1. From the dashboard, view recent cases and statistics
2. Click **View All Cases** or **My Cases** for the full list
3. Use the search bar to find specific cases by:
- Client name
- Client reference 1 or 2
- Claim number
#### Adding Comments
1. Open a case detail view
2. Scroll to the Comments section
3. Enter your comment
4. Select comment type (General, Question, Update, Approval)
5. Click **Add Comment**
#### Uploading Documents
1. Open a case detail view
2. Go to the Documents section
3. Click **Upload Document**
4. Select document type (Full Application, Page 11, Page 12, etc.)
5. Choose the file and add revision notes
6. Click **Upload**
---
### For Sales Representatives
#### Starting a New Assessment
1. Log in to the portal
2. Click **New Assessment**
3. Fill in client information:
- Name, address, contact details
- Client references
4. Record wheelchair specifications:
- Measurements (seat width, depth, height)
- Product types (cushion, backrest, frame, wheels)
5. Document accessibility and mobility needs
6. Click **Save & Continue**
#### Capturing Signatures
1. After saving assessment data, click **Proceed to Signatures**
2. **Page 11 (Authorizer):**
- Have the OT sign on the canvas
- Enter their printed name
- Click **Save Signature**
3. **Page 12 (Client):**
- Have the client sign on the canvas
- Enter their printed name
- Click **Save Signature**
#### Completing the Assessment
1. Once both signatures are captured, click **Complete Assessment**
2. The system will:
- Create a new customer record (if needed)
- Generate a draft Sale Order
- Attach signed documents
- Send notification emails
3. The assessment moves to "Completed" status
---
## API Reference
### Assessment Model Methods
```python
# Complete an assessment and create Sale Order
assessment.action_complete()
# Get formatted specifications for order notes
specs = assessment._format_specifications_for_order()
# Ensure partner exists or create new
partner = assessment._ensure_partner()
```
### Sale Order Portal Methods
```python
# Get safe data for portal display (no costs)
data = order.get_portal_display_data()
# Search cases for authorizer
cases = SaleOrder.get_authorizer_portal_cases(
partner_id=123,
search_query='Smith',
limit=50,
offset=0
)
# Search cases for sales rep
cases = SaleOrder.get_sales_rep_portal_cases(
user_id=456,
search_query='wheelchair',
limit=50,
offset=0
)
```
### Partner Methods
```python
# Grant portal access programmatically
partner.action_grant_portal_access()
# Check if partner is an authorizer
if partner.is_authorizer:
cases = partner.assigned_case_count
```
### Document Methods
```python
# Get all documents for an order
docs = ADPDocument.get_documents_for_order(sale_order_id)
# Get revision history
history = document.get_revision_history()
```
---
## Troubleshooting
### Common Errors
#### Error: `Invalid field 'in_portal' in 'portal.wizard.user'`
**Cause:** Odoo 19 changed the portal wizard API, removing the `in_portal` field.
**Solution:** The `action_grant_portal_access` method has been updated to:
1. First attempt using the standard portal wizard
2. If that fails, fall back to direct user creation with portal group assignment
```python
# The fallback code creates the user directly:
portal_group = self.env.ref('base.group_portal')
portal_user = self.env['res.users'].sudo().create({
'name': self.name,
'login': self.email,
'email': self.email,
'partner_id': self.id,
'groups_id': [(6, 0, [portal_group.id])],
})
```
---
#### Error: `Invalid view type: 'tree'`
**Cause:** Odoo 19 renamed `<tree>` views to `<list>`.
**Solution:** Replace all `<tree>` tags with `<list>` in XML view definitions:
```xml
<!-- Old (Odoo 18 and earlier) -->
<tree>...</tree>
<!-- New (Odoo 19) -->
<list>...</list>
```
---
#### Error: `Invalid field 'category_id' in 'res.groups'`
**Cause:** Odoo 19 no longer supports `category_id` in `res.groups` XML definitions.
**Solution:** Remove the `<field name="category_id">` element from security group definitions:
```xml
<!-- Remove this line -->
<field name="category_id" ref="base.module_category_sales"/>
```
---
#### Error: `DeprecationWarning: @route(type='json') is deprecated`
**Cause:** Odoo 19 uses `type='jsonrpc'` instead of `type='json'`.
**Solution:** Update route decorators:
```python
# Old
@http.route('/my/endpoint', type='json', auth='user')
# New
@http.route('/my/endpoint', type='jsonrpc', auth='user')
```
---
### Portal Access Issues
#### User can't see cases in portal
1. Verify the partner has `is_authorizer` or `is_sales_rep_portal` checked
2. Verify the `authorizer_portal_user_id` is set
3. For authorizers, verify the Sale Order has `x_fc_authorizer_id` set to their partner ID
4. For sales reps, verify the Sale Order has `user_id` set to their user ID
#### Email notifications not sending
1. Check that the outgoing mail server is configured in Odoo
2. Verify the email templates exist and are active
3. Check the mail queue (Settings > Technical > Email > Emails)
4. Review the Odoo logs for mail errors
---
### Debug Logging
Enable debug logging for this module:
```python
import logging
_logger = logging.getLogger('fusion_authorizer_portal')
_logger.setLevel(logging.DEBUG)
```
Or in Odoo configuration:
```ini
[options]
log_handler = fusion_authorizer_portal:DEBUG
```
---
## Changelog
### Version 19.0.1.0.0 (Initial Release)
**New Features:**
- Authorizer Portal with case management
- Sales Rep Portal with assessment forms
- Wheelchair Assessment model with 50+ fields
- Digital signature capture (Pages 11 & 12)
- Document management with revision tracking
- Real-time search functionality
- Email notifications for key events
- Portal access management from partner form
**Technical:**
- Compatible with Odoo Enterprise v19
- Integrates with fusion_claims module
- Mobile-responsive portal design
- Touch-friendly signature pad
- AJAX-powered search
**Bug Fixes:**
- Fixed `in_portal` field error in Odoo 19 portal wizard
- Fixed `tree` to `list` view type for Odoo 19
- Fixed `category_id` error in security groups
- Fixed `type='json'` deprecation warning
---
## File Structure
```
fusion_authorizer_portal/
├── __init__.py
├── __manifest__.py
├── README.md
├── controllers/
│ ├── __init__.py
│ ├── portal_main.py # Authorizer & Sales Rep portal routes
│ └── portal_assessment.py # Assessment routes
├── data/
│ ├── mail_template_data.xml # Email templates & sequences
│ └── portal_menu_data.xml # Portal menu items
├── models/
│ ├── __init__.py
│ ├── adp_document.py # Document management model
│ ├── assessment.py # Assessment model
│ ├── authorizer_comment.py # Comments model
│ ├── res_partner.py # Partner extensions
│ └── sale_order.py # Sale Order extensions
├── security/
│ ├── ir.model.access.csv # Access rights
│ └── portal_security.xml # Groups & record rules
├── static/
│ └── src/
│ ├── css/
│ │ └── portal_style.css # Portal styling
│ └── js/
│ ├── assessment_form.js # Form enhancements
│ ├── portal_search.js # Real-time search
│ └── signature_pad.js # Signature capture
└── views/
├── assessment_views.xml # Assessment backend views
├── portal_templates.xml # Portal QWeb templates
├── res_partner_views.xml # Partner form extensions
└── sale_order_views.xml # Sale Order extensions
```
---
## Support
For support or feature requests, contact:
- **Email:** support@fusionclaims.com
- **Website:** https://fusionclaims.com
---
*Last Updated: January 2026*

View File

@@ -1,100 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.0.9',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
Fusion Authorizer & Sales Rep Portal
=====================================
This module provides external portal access for:
**Authorizers (Occupational Therapists)**
- View assigned ADP cases
- Upload documents (ADP applications, signed pages)
- Add comments to cases
- Complete assessments with clients
- Capture digital signatures for ADP pages 11 & 12
**Sales Representatives**
- View their sales cases
- Start new client assessments
- Record wheelchair specifications and measurements
- Capture client signatures
- Track assessment progress
**Assessment System**
- Client information collection
- Wheelchair specifications (seat width, depth, height, etc.)
- Accessibility and mobility needs documentation
- Digital signature capture for ADP pages 11 & 12
- Automatic draft Sale Order creation
- Document distribution to all parties
- Automated email notifications
**Features**
- Real-time client search
- Document version tracking
- Mobile-friendly signature capture
- Email notifications for status changes
- Secure portal access with role-based permissions
""",
'author': 'Fusion Claims',
'website': 'https://fusionclaims.com',
'license': 'LGPL-3',
'depends': [
'base',
'sale',
'portal',
'website',
'mail',
'calendar',
'knowledge',
'fusion_claims',
],
'data': [
# Security
'security/portal_security.xml',
'security/ir.model.access.csv',
# Data
'data/mail_template_data.xml',
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/pdf_template_views.xml',
# Portal Templates
'views/portal_templates.xml',
'views/portal_assessment_express.xml',
'views/portal_pdf_editor.xml',
'views/portal_accessibility_templates.xml',
'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
],
'assets': {
'web.assets_backend': [
'fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml',
'fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js',
],
'web.assets_frontend': [
'fusion_authorizer_portal/static/src/css/portal_style.css',
'fusion_authorizer_portal/static/src/css/technician_portal.css',
'fusion_authorizer_portal/static/src/js/portal_search.js',
'fusion_authorizer_portal/static/src/js/assessment_form.js',
'fusion_authorizer_portal/static/src/js/signature_pad.js',
'fusion_authorizer_portal/static/src/js/loaner_portal.js',
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -1,218 +0,0 @@
# -*- coding: utf-8 -*-
# Fusion PDF Field Editor Controller
# Provides routes for the visual drag-and-drop field position editor
import base64
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionPdfEditorController(http.Controller):
"""Controller for the PDF field position visual editor."""
# ================================================================
# Editor Page
# ================================================================
@http.route('/fusion/pdf-editor/<int:template_id>', type='http', auth='user', website=True)
def pdf_field_editor(self, template_id, **kw):
"""Render the visual field editor for a PDF template."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return request.redirect('/web')
# Get preview image for page 1
preview_url = ''
preview = template.preview_ids.filtered(lambda p: p.page == 1)
if preview and preview[0].image:
preview_url = f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'
fields = template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
])
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
'template': template,
'fields': fields,
'preview_url': preview_url,
})
# ================================================================
# JSONRPC: Get fields for template
# ================================================================
@http.route('/fusion/pdf-editor/fields', type='json', auth='user')
def get_fields(self, template_id, **kw):
"""Return all fields for a template."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return []
return template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
])
# ================================================================
# JSONRPC: Update field position/properties
# ================================================================
@http.route('/fusion/pdf-editor/update-field', type='json', auth='user')
def update_field(self, field_id, values, **kw):
"""Update a field's position or properties."""
field = request.env['fusion.pdf.template.field'].browse(field_id)
if not field.exists():
return {'error': 'Field not found'}
# Filter to allowed fields only
allowed = {
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
'text_align',
}
safe_values = {k: v for k, v in values.items() if k in allowed}
if safe_values:
field.write(safe_values)
return {'success': True}
# ================================================================
# JSONRPC: Create new field
# ================================================================
@http.route('/fusion/pdf-editor/create-field', type='json', auth='user')
def create_field(self, **kw):
"""Create a new field on a template."""
template_id = kw.get('template_id')
if not template_id:
return {'error': 'Missing template_id'}
vals = {
'template_id': int(template_id),
'name': kw.get('name', 'new_field'),
'label': kw.get('label', 'New Field'),
'field_type': kw.get('field_type', 'text'),
'field_key': kw.get('field_key', kw.get('name', '')),
'page': int(kw.get('page', 1)),
'pos_x': float(kw.get('pos_x', 0.3)),
'pos_y': float(kw.get('pos_y', 0.3)),
'width': float(kw.get('width', 0.150)),
'height': float(kw.get('height', 0.015)),
'font_size': float(kw.get('font_size', 10)),
}
field = request.env['fusion.pdf.template.field'].create(vals)
return {'id': field.id, 'success': True}
# ================================================================
# JSONRPC: Delete field
# ================================================================
@http.route('/fusion/pdf-editor/delete-field', type='json', auth='user')
def delete_field(self, field_id, **kw):
"""Delete a field from a template."""
field = request.env['fusion.pdf.template.field'].browse(field_id)
if field.exists():
field.unlink()
return {'success': True}
# ================================================================
# JSONRPC: Get page preview image URL
# ================================================================
@http.route('/fusion/pdf-editor/page-image', type='json', auth='user')
def get_page_image(self, template_id, page, **kw):
"""Return the preview image URL for a specific page."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return {'image_url': ''}
preview = template.preview_ids.filtered(lambda p: p.page == page)
if preview and preview[0].image:
return {'image_url': f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'}
return {'image_url': ''}
# ================================================================
# Upload page preview image (from editor)
# ================================================================
@http.route('/fusion/pdf-editor/upload-preview', type='http', auth='user',
methods=['POST'], csrf=True, website=True)
def upload_preview_image(self, **kw):
"""Upload a preview image for a template page directly from the editor."""
template_id = int(kw.get('template_id', 0))
page = int(kw.get('page', 1))
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists():
return json.dumps({'error': 'Template not found'})
image_file = request.httprequest.files.get('preview_image')
if not image_file:
return json.dumps({'error': 'No image uploaded'})
image_data = base64.b64encode(image_file.read())
# Find or create preview for this page
preview = template.preview_ids.filtered(lambda p: p.page == page)
if preview:
preview[0].write({'image': image_data, 'image_filename': image_file.filename})
else:
request.env['fusion.pdf.template.preview'].create({
'template_id': template_id,
'page': page,
'image': image_data,
'image_filename': image_file.filename,
})
_logger.info("Uploaded preview image for template %s page %d", template.name, page)
return request.redirect(f'/fusion/pdf-editor/{template_id}')
# ================================================================
# Preview: Generate sample filled PDF
# ================================================================
@http.route('/fusion/pdf-editor/preview/<int:template_id>', type='http', auth='user')
def preview_pdf(self, template_id, **kw):
"""Generate a preview filled PDF with sample data."""
template = request.env['fusion.pdf.template'].browse(template_id)
if not template.exists() or not template.pdf_file:
return request.redirect('/web')
# Build sample data for preview
sample_context = {
'client_last_name': 'Smith',
'client_first_name': 'John',
'client_middle_name': 'A',
'client_health_card': '1234-567-890',
'client_health_card_version': 'AB',
'client_street': '123 Main Street',
'client_unit': 'Unit 4B',
'client_city': 'Toronto',
'client_state': 'Ontario',
'client_postal_code': 'M5V 2T6',
'client_phone': '(416) 555-0123',
'client_email': 'john.smith@example.com',
'client_weight': '185',
'consent_applicant': True,
'consent_agent': False,
'consent_date': '2026-02-08',
'agent_last_name': '',
'agent_first_name': '',
}
try:
pdf_bytes = template.generate_filled_pdf(sample_context)
headers = [
('Content-Type', 'application/pdf'),
('Content-Disposition', f'inline; filename="preview_{template.name}.pdf"'),
]
return request.make_response(pdf_bytes, headers=headers)
except Exception as e:
_logger.error("PDF preview generation failed: %s", e)
return request.redirect(f'/fusion/pdf-editor/{template_id}?error=preview_failed')

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== BATCH SERVER ACTIONS FOR RES.PARTNER ==================== -->
<!-- Mark as Authorizer - Batch Action (Gear Menu) -->
<record id="action_mark_as_authorizer" model="ir.actions.server">
<field name="name">Mark as Authorizer</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = records.action_mark_as_authorizer()
</field>
</record>
<!-- Send Portal Invitation - Batch Action (Gear Menu) -->
<record id="action_batch_send_invitation" model="ir.actions.server">
<field name="name">Send Portal Invitation</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
action = records.action_batch_send_portal_invitation()
</field>
</record>
<!-- Mark as Authorizer & Send Invitation (Combined) - Batch Action (Gear Menu) -->
<record id="action_mark_and_invite" model="ir.actions.server">
<field name="name">Mark as Authorizer &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

@@ -1,172 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Sequence for Assessment Reference - noupdate=1 prevents reset on module upgrade -->
<data noupdate="1">
<record id="seq_fusion_assessment" model="ir.sequence">
<field name="name">Assessment Sequence</field>
<field name="code">fusion.assessment</field>
<field name="prefix">ASM-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
<!-- Sequence for Accessibility Assessment Reference -->
<record id="seq_fusion_accessibility_assessment" model="ir.sequence">
<field name="name">Accessibility Assessment Sequence</field>
<field name="code">fusion.accessibility.assessment</field>
<field name="prefix">ACC-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
<!-- ================================================================= -->
<!-- Email Template: Case Assigned to Authorizer -->
<!-- ================================================================= -->
<record id="mail_template_case_assigned" model="mail.template">
<field name="name">Authorizer Portal: Case Assigned</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">New Case Assigned: {{ object.name }} - {{ object.partner_id.name }}</field>
<field name="email_from">{{ (object.company_id.email or object.user_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.x_fc_authorizer_id.email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.company_id.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">New Case Assigned</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">A new ADP case has been assigned to you.</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Case Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Case</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.partner_id.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options='{"widget": "date"}'/></td></tr>
</table>
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#2B6CB0;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.user_id.name or object.company_id.name"/></strong><br/><span style="color:#718096;"><t t-out="object.company_id.name"/></span></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from <t t-out="object.company_id.name"/>.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
<!-- Status Changed template removed - redundant.
Each workflow transition sends its own detailed email from fusion_claims.
Record kept to avoid XML ID reference errors on upgrade. -->
<record id="mail_template_status_changed" model="mail.template">
<field name="name">Authorizer Portal: Status Changed (Disabled)</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Case Update: {{ object.name }}</field>
<field name="body_html" type="html"><p>This template is no longer in use.</p></field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ================================================================= -->
<!-- Email Template: Assessment Complete - To Authorizer -->
<!-- ================================================================= -->
<record id="mail_template_assessment_complete_authorizer" model="mail.template">
<field name="name">Assessment Complete - Authorizer Notification</field>
<field name="model_id" ref="model_fusion_assessment"/>
<field name="subject">Assessment Complete: {{ object.reference }} - {{ object.client_name }}</field>
<field name="email_from">{{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.authorizer_id.email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.sales_rep_id.company_id.name or object.env.company.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Assessment Complete</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">The assessment for <strong style="color:#2d3748;"><t t-out="object.client_name"/></strong> has been completed and a sale order has been created.</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Assessment Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.client_name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.assessment_date" t-options='{"widget": "date"}'/></td></tr>
<t t-if="object.sale_order_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Sale Order</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.sale_order_id.name"/></td></tr>
</t>
</table>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;"><strong>Next steps:</strong> Please submit the ADP application (including pages 11-12 signed by the client) so we can proceed with the claim submission.</p>
</div>
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#38a169;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.sales_rep_id.name or 'The Team'"/></strong></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from the ADP Claims Management System.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
<!-- ================================================================= -->
<!-- Email Template: Assessment Complete - To Client -->
<!-- ================================================================= -->
<record id="mail_template_assessment_complete_client" model="mail.template">
<field name="name">Assessment Complete - Client Notification</field>
<field name="model_id" ref="model_fusion_assessment"/>
<field name="subject">Your Assessment is Complete - {{ object.reference }}</field>
<field name="email_from">{{ (object.sales_rep_id.company_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.client_email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.sales_rep_id.company_id.name or object.env.company.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">Assessment Complete</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">Dear <strong style="color:#2d3748;"><t t-out="object.client_name"/></strong>, thank you for completing your assessment with us.</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Summary</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.assessment_date" t-options='{"widget": "date"}'/></td></tr>
<t t-if="object.authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Therapist</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.authorizer_id.name"/></td></tr>
</t>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0 0 8px 0;font-size:14px;color:#2d3748;font-weight:600;">What happens next:</p>
<ol style="margin:0;padding-left:20px;font-size:14px;line-height:1.6;color:#2d3748;">
<li>Your assessment will be reviewed by our team</li>
<li>We will submit the ADP application on your behalf</li>
<li>You will be notified once approval is received</li>
<li>Your equipment will be ordered and delivered</li>
</ol>
</div>
<p style="color:#2d3748;font-size:14px;line-height:1.5;">If you have any questions, please do not hesitate to contact us.</p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.sales_rep_id.name or 'The Team'"/></strong></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from the ADP Claims Management System.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
<!-- ================================================================= -->
<!-- Email Template: Document Uploaded -->
<!-- ================================================================= -->
<record id="mail_template_document_uploaded" model="mail.template">
<field name="name">Authorizer Portal: Document Uploaded</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">New Document Uploaded: {{ object.name }}</field>
<field name="email_from">{{ (object.company_id.email or 'noreply@example.com') }}</field>
<field name="email_to">{{ object.x_fc_authorizer_id.email }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;"><t t-out="object.company_id.name"/></p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;">New Document Available</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">A new document has been uploaded for case <strong style="color:#2d3748;"><t t-out="object.name"/></strong> (<t t-out="object.partner_id.name"/>).</p>
<p style="text-align:center;margin:28px 0;"><a href="/my/authorizer" style="display:inline-block;background:#2B6CB0;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-size:14px;font-weight:600;">View in Portal</a></p>
<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">Best regards,<br/><strong><t t-out="object.company_id.name"/></strong></p>
</div>
<div style="padding:16px 28px;text-align:center;"><p style="color:#a0aec0;font-size:11px;margin:0;">This is an automated notification from <t t-out="object.company_id.name"/>.</p></div>
</div>
</field>
<field name="auto_delete" eval="False"/>
</record>
</odoo>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- This file can be used for portal menu customizations if needed -->
<!-- Currently, portal menus are handled by the templates -->
</odoo>

View File

@@ -1,432 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ================================================================== -->
<!-- AUTHORIZER WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_authorizer">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Authorizer Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/> - Assistive Devices Program
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Authorizer Portal.
This portal is designed to streamline the ADP (Assistive Devices Program) process
and keep you connected with your assigned cases.
</p>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>View Assigned Cases</strong><br/>
Access all ADP cases assigned to you with real-time status updates.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Complete Assessments</strong><br/>
Fill out assessments online with measurements, photos, and specifications.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track Application Status</strong><br/>
Monitor the progress of ADP applications from submission to approval.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f8f9fa; text-align: center; font-size: 20px;">4</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Upload Documents</strong><br/>
Upload ADP applications, signed pages 11 and 12, and supporting documentation.
</td>
</tr>
</table>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Navigate to <strong>My Cases</strong> from the portal menu to see your assigned ADP cases.</li>
<li>Click on any case to view details, upload documents, or add comments.</li>
<li>To start a new assessment, go to <strong>Assessments</strong> and click <strong>New Assessment</strong>.</li>
<li>Complete the assessment form with all required measurements and photos.</li>
</ol>
<h2 style="color: #e53e3e; border-bottom: 2px solid #e53e3e; padding-bottom: 8px;">Important Reminders</h2>
<div style="background: #fff5f5; border-left: 4px solid #e53e3e; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li><strong>Assessment Validity:</strong> Assessments are valid for 3 months from the completion date.</li>
<li><strong>Application Submission:</strong> Please submit the ADP application promptly after the assessment is completed.</li>
<li><strong>Page 11:</strong> Must be signed by the applicant (or authorized agent: spouse, parent, legal guardian, public trustee, or power of attorney).</li>
<li><strong>Page 12:</strong> Must be signed by the authorizer and the vendor.</li>
</ul>
</div>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions or need assistance, please contact our office:
</p>
<div style="background: #f0f4ff; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- SALES REP WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_sales_rep">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Sales Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/> - Sales Dashboard
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Sales Portal.
This is your hub for managing sales orders, completing assessments, and tracking ADP cases.
</p>
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Sales Dashboard</strong><br/>
View all your sales orders, filter by status, sale type, and search by client name or order number.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Complete Assessments</strong><br/>
Start ADP Express Assessments and Accessibility Assessments (stair lifts, platform lifts, ceiling lifts, ramps, bathroom modifications).
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Proof of Delivery</strong><br/>
Get proof of delivery signed by clients directly from your phone or tablet.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">4</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Loaner Equipment</strong><br/>
Track loaner equipment checkouts and returns.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0fff4; text-align: center; font-size: 20px;">5</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track ADP Cases</strong><br/>
Monitor ADP application status from assessment through approval to billing.
</td>
</tr>
</table>
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Go to <strong>Sales Dashboard</strong> to see all your cases at a glance.</li>
<li>Use the <strong>search bar</strong> and <strong>filters</strong> to quickly find cases by client name, order number, sale type, or status.</li>
<li>Click on any case to view full details and take action.</li>
<li>To start a new assessment, go to <strong>Assessments</strong> and select the appropriate assessment type.</li>
<li>For deliveries, navigate to <strong>Signature Requests</strong> to collect proof of delivery signatures.</li>
</ol>
<h2 style="color: #ff9800; border-bottom: 2px solid #ff9800; padding-bottom: 8px;">Tips for Success</h2>
<div style="background: #fff8e1; border-left: 4px solid #ff9800; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li>Take clear photos during assessments - they will be attached to the case automatically.</li>
<li>Complete all required measurements before submitting an assessment.</li>
<li>Follow up on cases in <strong>Waiting for Application</strong> status to keep the process moving.</li>
<li>Always collect proof of delivery signatures at the time of delivery.</li>
</ul>
</div>
<h2 style="color: #5ba848; border-bottom: 2px solid #5ba848; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions or need assistance, please contact the office:
</p>
<div style="background: #f0fff4; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- TECHNICIAN WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_technician">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #3a8fb7 0%, #2e7aad 60%, #1a6b9a 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to the Technician Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/> - Delivery and Service
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Technician Portal.
This portal helps you manage your assigned deliveries and collect proof of delivery signatures.
</p>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>View Assigned Deliveries</strong><br/>
See all deliveries assigned to you with client details, addresses, and product information.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Collect Proof of Delivery</strong><br/>
Get the client's signature on the proof of delivery document directly from your device.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #fff0f3; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track Delivery Status</strong><br/>
Monitor which deliveries are pending, in progress, or completed.
</td>
</tr>
</table>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Navigate to <strong>My Deliveries</strong> to see all deliveries assigned to you.</li>
<li>Click on a delivery to view the client details and delivery address.</li>
<li>Go to <strong>Signature Requests</strong> to collect proof of delivery signatures.</li>
<li>After collecting the signature, the signed POD is automatically attached to the order.</li>
</ol>
<h2 style="color: #e53e3e; border-bottom: 2px solid #e53e3e; padding-bottom: 8px;">Important Reminders</h2>
<div style="background: #fff5f5; border-left: 4px solid #e53e3e; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li><strong>Always get POD signed before leaving.</strong> The proof of delivery is required for billing.</li>
<li>Ensure the client's <strong>name</strong> and <strong>date</strong> are filled in on the delivery form.</li>
<li>If the client is unavailable, contact the office immediately.</li>
<li>Report any product issues or damages to the office right away.</li>
</ul>
</div>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions or need assistance, please contact the office:
</p>
<div style="background: #fff0f3; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- GENERAL PORTAL USER (CLIENT) WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_client">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to Your Portal</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
<t t-out="company_name"/>
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Dear <strong><t t-out="user_name"/></strong>,
</p>
<p style="font-size: 15px; line-height: 1.7;">
Welcome to the <strong><t t-out="company_name"/></strong> Portal.
Here you can view your orders, track their status, and access your documents.
</p>
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">What You Can Do</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>View Your Orders</strong><br/>
Access all your orders and track their current status.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Track Status</strong><br/>
See real-time updates on your application and delivery status.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #f0f8ff; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Access Documents</strong><br/>
Download invoices, delivery receipts, and other important documents.
</td>
</tr>
</table>
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">Getting Started</h2>
<ol style="font-size: 14px; line-height: 1.8;">
<li>Click on <strong>My Orders</strong> from the portal menu to see your orders.</li>
<li>Click on any order to view its full details and status.</li>
<li>Download documents by clicking the download button next to each file.</li>
</ol>
<h2 style="color: #3a8fb7; border-bottom: 2px solid #3a8fb7; padding-bottom: 8px;">Need Help?</h2>
<p style="font-size: 14px; line-height: 1.7;">
If you have any questions, please don't hesitate to reach out:
</p>
<div style="background: #f0f8ff; padding: 15px 20px; border-radius: 8px; font-size: 14px;">
<strong><t t-out="company_name"/></strong><br/>
Email: <t t-out="company_email"/><br/>
Phone: <t t-out="company_phone"/>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- INTERNAL STAFF WELCOME ARTICLE TEMPLATE -->
<!-- ================================================================== -->
<template id="welcome_article_internal">
<div style="font-family: Arial, Helvetica, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%); padding: 30px 40px; border-radius: 12px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 28px;">Welcome to <t t-out="company_name"/></h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 16px;">
Fusion Claims - Internal Operations
</p>
</div>
<p style="font-size: 15px; line-height: 1.7;">
Welcome, <strong><t t-out="user_name"/></strong>!
</p>
<p style="font-size: 15px; line-height: 1.7;">
This is your quick-start guide to the <strong><t t-out="company_name"/></strong> system.
Below you'll find an overview of the key areas and how to navigate the system.
</p>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">System Overview</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; width: 40px; text-align: center; font-size: 20px;">1</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>ADP Case Management</strong><br/>
Process ADP claims through the full workflow: Assessment, Application, Submission, Approval, Billing, and Case Close.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">2</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Sales Orders</strong><br/>
Manage quotations, sales orders, invoicing, and delivery for all sale types (ADP, ODSP, Private, Insurance, etc.).
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">3</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Device Codes</strong><br/>
Look up and manage ADP device codes, prices, and serial number requirements.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">4</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Inventory and Loaner Tracking</strong><br/>
Manage product inventory, track loaner equipment checkouts and returns.
</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #e0e0e0; background: #eaf4fd; text-align: center; font-size: 20px;">5</td>
<td style="padding: 12px; border: 1px solid #e0e0e0;">
<strong>Delivery Management</strong><br/>
Assign technicians, track deliveries, and manage proof of delivery documents.
</td>
</tr>
</table>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">ADP Workflow Quick Reference</h2>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 15px 0;">
<ol style="font-size: 14px; line-height: 2.0; margin: 0;">
<li><strong>Quotation</strong> - Create the sales order</li>
<li><strong>Assessment Scheduled</strong> - Schedule the client assessment</li>
<li><strong>Assessment Completed</strong> - Complete the on-site assessment</li>
<li><strong>Waiting for Application</strong> - Wait for the authorizer to submit the application</li>
<li><strong>Application Received</strong> - Upload the ADP application and signed pages</li>
<li><strong>Ready for Submission</strong> - Verify all documents are ready</li>
<li><strong>Application Submitted</strong> - Submit to ADP</li>
<li><strong>Accepted / Approved</strong> - ADP accepts and approves the case</li>
<li><strong>Ready for Delivery</strong> - Prepare and deliver the product</li>
<li><strong>Billed to ADP</strong> - Submit the claim for payment</li>
<li><strong>Case Closed</strong> - Finalize the case</li>
</ol>
</div>
<h2 style="color: #2e7aad; border-bottom: 2px solid #2e7aad; padding-bottom: 8px;">Key Menu Locations</h2>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 14px;">
<tr style="background: #eaf4fd;">
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">ADP Claims</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Sales menu - ADP Claims - filter by workflow stage</td>
</tr>
<tr>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Device Codes</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Sales menu - Configuration - ADP Device Codes</td>
</tr>
<tr style="background: #eaf4fd;">
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Fusion Claims Settings</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Settings - Fusion Claims (scroll down)</td>
</tr>
<tr>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0; font-weight: bold;">Contacts (Authorizers)</td>
<td style="padding: 10px 12px; border: 1px solid #e0e0e0;">Contacts - filter by "Authorizers"</td>
</tr>
</table>
<h2 style="color: #ff9800; border-bottom: 2px solid #ff9800; padding-bottom: 8px;">Tips</h2>
<div style="background: #fff8e1; border-left: 4px solid #ff9800; padding: 15px; margin: 15px 0; border-radius: 0 8px 8px 0;">
<ul style="margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
<li>Use <strong>filters and search</strong> to quickly find cases by status, client, or authorizer.</li>
<li>Check the <strong>chatter</strong> (message log) on each case for the full history of changes and communications.</li>
<li>Use the <strong>status bar</strong> at the top of each case to see where it is in the workflow.</li>
<li>All automated emails are logged in the chatter for audit purposes.</li>
<li>Use <strong>scheduled activities</strong> to set reminders and follow-ups.</li>
</ul>
</div>
</div>
</template>
</data>
</odoo>

View File

@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
from . import res_partner
from . import res_users
from . import authorizer_comment
from . import adp_document
from . import assessment
from . import accessibility_assessment
from . import sale_order
from . import pdf_template

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