Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from . import email_builder_mixin
from . import adp_posting_schedule
from . import res_company
from . import res_config_settings
from . import fusion_central_config
from . import fusion_adp_device_code
from . import product_template
from . import product_product
from . import sale_order
from . import sale_order_line
from . import account_move
from . import account_move_line
from . import account_payment
from . import account_payment_method_line
from . import submission_history
from . import fusion_loaner_checkout
from . import fusion_loaner_history
from . import client_profile
from . import adp_application_data
from . import xml_parser
from . import client_chat
from . import ai_agent_ext
from . import dashboard
from . import res_partner
from . import res_users
from . import technician_task
from . import technician_location
from . import push_subscription
from . import pdf_template_inherit

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import models, fields, api
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
# ==========================================================================
# SERIAL NUMBER AND DEVICE PLACEMENT
# ==========================================================================
x_fc_serial_number = fields.Char(
string='Serial Number',
help='Serial number for this product',
)
x_fc_device_placement = fields.Selection(
selection=[
('L', 'Left'),
('R', 'Right'),
('NA', 'N/A'),
],
string='Device Placement',
default='NA',
help='Device placement position (Left/Right/N/A)',
)
# ==========================================================================
# DEDUCTION FIELDS
# ==========================================================================
x_fc_deduction_type = fields.Selection(
selection=[
('none', 'No Deduction'),
('pct', 'Percentage'),
('amt', 'Amount'),
],
string='Deduction Type',
default='none',
help='Type of ADP deduction applied to this line',
)
x_fc_deduction_value = fields.Float(
string='Deduction Value',
digits='Product Price',
help='Deduction value (percentage if PCT, dollar amount if AMT)',
)
# ==========================================================================
# ADP REFERENCE FIELDS
# ==========================================================================
x_fc_adp_max_price = fields.Float(
string='ADP Max Price',
digits='Product Price',
help='Maximum price ADP will cover for this device (from mobility manual)',
)
x_fc_sn_required = fields.Boolean(
string='S/N Required',
help='Is serial number required for this device?',
)
# ==========================================================================
# ADP DEVICE APPROVAL TRACKING
# ==========================================================================
x_fc_adp_approved = fields.Boolean(
string='ADP Approved',
default=False,
help='Was this device approved by ADP in the application approval?',
)
x_fc_adp_device_type = fields.Char(
string='ADP Device Type',
help='Device type from ADP mobility manual (for approval matching)',
)
# ==========================================================================
# ADP PORTIONS - Stored fields set during invoice creation
# ==========================================================================
x_fc_adp_portion = fields.Monetary(
string='ADP Portion',
currency_field='currency_id',
help='ADP portion for this line (calculated during invoice creation from device codes database)',
)
x_fc_client_portion = fields.Monetary(
string='Client Portion',
currency_field='currency_id',
help='Client portion for this line (calculated during invoice creation from device codes database)',
)
def _compute_adp_portions(self):
"""Compute ADP and client portions based on device codes database.
This is called during invoice type/client type changes to recalculate portions.
"""
self.action_recalculate_portions()
def action_recalculate_portions(self):
"""Manually recalculate ADP and client portions based on device codes database.
This can be called to recalculate portions if values are incorrect.
Uses the same logic as invoice creation.
"""
ADPDevice = self.env['fusion.adp.device.code'].sudo()
for line in self:
move = line.move_id
if not move or move.move_type not in ['out_invoice', 'out_refund']:
continue
if not line.product_id or line.quantity <= 0:
continue
# Get client type
client_type = move._get_client_type()
if client_type == 'REG':
base_adp_pct = 0.75
base_client_pct = 0.25
else:
base_adp_pct = 1.0
base_client_pct = 0.0
# Get ADP price from device codes database (priority)
device_code = line._get_adp_device_code()
adp_price = 0
if device_code:
adp_device = ADPDevice.search([
('device_code', '=', device_code),
('active', '=', True)
], limit=1)
if adp_device:
adp_price = adp_device.adp_price or 0
# Fallback to product fields
if not adp_price and line.product_id:
product_tmpl = line.product_id.product_tmpl_id
if hasattr(product_tmpl, 'x_fc_adp_price'):
adp_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
# (Studio field fallback removed)
# Fallback to line max price or unit price
if not adp_price:
adp_price = line.x_fc_adp_max_price or line.price_unit
qty = line.quantity
adp_base_total = adp_price * qty
# Apply deductions
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
effective_adp_pct = base_adp_pct * (line.x_fc_deduction_value / 100)
adp_portion = adp_base_total * effective_adp_pct
client_portion = adp_base_total - adp_portion
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
base_adp_amount = adp_base_total * base_adp_pct
adp_portion = max(0, base_adp_amount - line.x_fc_deduction_value)
client_portion = adp_base_total - adp_portion
else:
adp_portion = adp_base_total * base_adp_pct
client_portion = adp_base_total * base_client_pct
line.write({
'x_fc_adp_portion': adp_portion,
'x_fc_client_portion': client_portion,
'x_fc_adp_max_price': adp_price,
})
# ==========================================================================
# GETTER METHODS
# ==========================================================================
def _get_adp_device_code(self):
"""Get ADP device code from product.
Checks multiple sources in order and validates against ADP device database:
1. x_fc_adp_device_code (module field) - verified in ADP database
2. x_adp_code (Studio/custom field) - verified in ADP database
3. default_code - verified in ADP database
4. Code in parentheses in product name (e.g., "Product Name (SE0001109)")
Returns empty string if no valid ADP code found.
"""
import re
self.ensure_one()
if not self.product_id:
return ''
product_tmpl = self.product_id.product_tmpl_id
ADPDevice = self.env['fusion.adp.device.code'].sudo()
# 1. Check x_fc_adp_device_code (module field)
code = ''
if hasattr(product_tmpl, 'x_fc_adp_device_code'):
code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
# Verify code exists in ADP database
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
# 2. Check x_adp_code (Studio/custom field)
if hasattr(product_tmpl, 'x_adp_code'):
code = getattr(product_tmpl, 'x_adp_code', '') or ''
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
# 3. Check default_code - ONLY if it's a valid ADP code
code = self.product_id.default_code or ''
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
# 4. Try to extract code from product name in parentheses
# E.g., "[MXA-1618] GEOMATRIX SILVERBACK MAX BACKREST - ACTIVE (SE0001109)"
product_name = self.product_id.name or ''
match = re.search(r'\(([A-Z]{2}\d{7})\)', product_name)
if match:
code = match.group(1)
if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
# 5. Last resort: check if there's a linked sale order line with ADP code
if self.sale_line_ids:
for sale_line in self.sale_line_ids:
if hasattr(sale_line, '_get_adp_device_code'):
sale_code = sale_line._get_adp_device_code()
if sale_code:
return sale_code
# No valid ADP code found - return empty to skip this line in export
return ''
def _get_serial_number(self):
"""Get serial number from mapped field or native field."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_aml_serial', 'x_fc_serial_number')
# Try mapped field first
if hasattr(self, field_name):
value = getattr(self, field_name, None)
if value:
return value
# Fallback to native field
return self.x_fc_serial_number or ''
def _get_device_placement(self):
"""Get device placement."""
self.ensure_one()
return self.x_fc_device_placement or 'NA'

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import models, fields, api
class AccountPayment(models.Model):
_inherit = 'account.payment'
x_fc_card_last_four = fields.Char(
string='Card Last 4 Digits',
size=4,
help='Last 4 digits of the card used for payment (for card payments only)',
)
x_fc_payment_note = fields.Char(
string='Payment Note',
help='Additional note for this payment (e.g., transaction reference)',
)

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import models, fields
class AccountPaymentMethodLine(models.Model):
_inherit = 'account.payment.method.line'
x_fc_requires_card_digits = fields.Boolean(
string='Requires Card Digits',
default=False,
help='If checked, the user must enter the last 4 digits of the card when using this payment method.',
)

View File

@@ -0,0 +1,670 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
import xml.etree.ElementTree as ET
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionAdpApplicationData(models.Model):
_name = 'fusion.adp.application.data'
_description = 'ADP Application Data (Parsed XML)'
_order = 'application_date desc, id desc'
_rec_name = 'display_name'
# ------------------------------------------------------------------
# LINKAGE
# ------------------------------------------------------------------
profile_id = fields.Many2one(
'fusion.client.profile', string='Client Profile',
ondelete='cascade', index=True,
)
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order',
ondelete='set null', index=True,
)
display_name = fields.Char(
string='Name', compute='_compute_display_name', store=True,
)
# ------------------------------------------------------------------
# COMPLETE XML DATA (for round-trip export fidelity)
# ------------------------------------------------------------------
xml_data_json = fields.Text(
string='Complete XML Data (JSON)',
help='Complete 1:1 JSON representation of all ~300 XML fields for export',
)
raw_xml = fields.Text(string='Raw XML Data')
# ------------------------------------------------------------------
# APPLICATION METADATA
# ------------------------------------------------------------------
device_category = fields.Selection([
('AA', 'Ambulation Aids (Section 2a)'),
('MD', 'Mobility Devices (Section 2b/2c)'),
('PS', 'Positioning/Seating (Section 2d)'),
('MX', 'Mixed/Multiple Sections'),
], string='Device Category')
version_number = fields.Char(string='Form Version')
application_date = fields.Date(string='Application Date')
# ------------------------------------------------------------------
# SECTION 1 - APPLICANT BIOGRAPHICAL INFORMATION
# ------------------------------------------------------------------
applicant_last_name = fields.Char(string='Last Name')
applicant_first_name = fields.Char(string='First Name')
applicant_middle_initial = fields.Char(string='Middle Initial')
health_card_number = fields.Char(string='Health Card Number', index=True)
health_card_version = fields.Char(string='Health Card Version')
date_of_birth = fields.Date(string='Date of Birth')
ltch_name = fields.Char(string='Long-Term Care Home')
# Address (individual fields, not combined)
unit_number = fields.Char(string='Unit Number')
street_number = fields.Char(string='Street Number')
street_name = fields.Char(string='Street Name')
rural_route = fields.Char(string='Lot/Concession/Rural Route')
city = fields.Char(string='City', index=True)
province = fields.Char(string='Province')
postal_code = fields.Char(string='Postal Code')
# Contact
home_phone = fields.Char(string='Home Phone')
business_phone = fields.Char(string='Business Phone')
phone_extension = fields.Char(string='Phone Extension')
# ------------------------------------------------------------------
# SECTION 1 - CONFIRMATION OF BENEFITS
# ------------------------------------------------------------------
receives_social_assistance = fields.Boolean(string='Receives Social Assistance')
benefit_type = fields.Char(string='Benefit Program')
benefit_owp = fields.Boolean(string='Ontario Works Program (OWP)')
benefit_odsp = fields.Boolean(string='Ontario Disability Support Program (ODSP)')
benefit_acsd = fields.Boolean(string='Assistance to Children with Severe Disabilities (ACSD)')
wsib_eligible = fields.Boolean(string='WSIB Eligible')
vac_eligible = fields.Boolean(string='Veterans Affairs Canada (VAC) Eligible')
# ------------------------------------------------------------------
# SECTION 2 - DEVICES AND ELIGIBILITY
# ------------------------------------------------------------------
medical_condition = fields.Text(string='Medical Condition / Diagnosis')
mobility_status = fields.Text(string='Functional Mobility Status')
# Previously funded equipment
prev_funded_none = fields.Boolean(string='None Previously Funded')
prev_funded_forearm = fields.Boolean(string='Forearm Crutches (Previously)')
prev_funded_wheeled = fields.Boolean(string='Wheeled Walker (Previously)')
prev_funded_manual = fields.Boolean(string='Manual Wheelchair (Previously)')
prev_funded_power = fields.Boolean(string='Power Wheelchair (Previously)')
prev_funded_addon = fields.Boolean(string='Power Add-On Device (Previously)')
prev_funded_scooter = fields.Boolean(string='Power Scooter (Previously)')
prev_funded_seating = fields.Boolean(string='Positioning Devices (Previously)')
prev_funded_tilt = fields.Boolean(string='Power Tilt System (Previously)')
prev_funded_recline = fields.Boolean(string='Power Recline System (Previously)')
prev_funded_legrests = fields.Boolean(string='Power Elevating Leg Rests (Previously)')
prev_funded_frame = fields.Boolean(string='Paediatric Standing Frame (Previously)')
prev_funded_stroller = fields.Boolean(string='Paediatric Specialty Stroller (Previously)')
# Devices currently required
device_forearm_crutches = fields.Boolean(string='Forearm Crutches')
device_wheeled_walker = fields.Boolean(string='Wheeled Walker')
device_manual_wheelchair = fields.Boolean(string='Manual Wheelchair')
device_ambulation_manual = fields.Boolean(string='Ambulation Aid + Manual Wheelchair')
device_dependent_wheelchair = fields.Boolean(string='Manual Wheelchair (Dependent)')
device_dynamic_tilt = fields.Boolean(string='Manual Dynamic Tilt Wheelchair')
device_manual_dynamic = fields.Boolean(string='Manual Dynamic Tilt (Dependent)')
device_manual_power_addon = fields.Boolean(string='Manual Wheelchair with Power Add-On')
device_power_base = fields.Boolean(string='Power Base Only')
device_power_scooter = fields.Boolean(string='Power Scooter Only')
device_ambulation_power = fields.Boolean(string='Ambulation Aid + Power Base/Scooter')
device_positioning = fields.Boolean(string='Positioning Devices (Seating)')
device_high_tech = fields.Boolean(string='High Technology Power Base')
device_standing_frame = fields.Boolean(string='Paediatric Standing Frame')
device_adp_funded_mods = fields.Boolean(string='Modifications to ADP Funded Device(s)')
device_non_adp_funded_mods = fields.Boolean(string='Modifications to Non ADP Funded Device(s)')
# ------------------------------------------------------------------
# SECTION 2A - AMBULATION AIDS (Walkers)
# ------------------------------------------------------------------
s2a_base_device = fields.Char(string='Walker Type')
s2a_paediatric_frame = fields.Char(string='Paediatric Frame')
s2a_forearm_crutches = fields.Char(string='Forearm Crutches Type')
s2a_none = fields.Char(string='None Selected')
s2a_reason = fields.Char(string='Reason for Application')
s2a_replacement_status = fields.Char(string='Replacement - Mobility Status Change')
s2a_replacement_size = fields.Char(string='Replacement - Body Size Change')
s2a_replacement_adp = fields.Char(string='Replacement - Equipment Worn Out')
s2a_replacement_special = fields.Char(string='Replacement - Special Circumstances')
s2a_confirm1 = fields.Char(string='Confirmation 1')
s2a_confirm2 = fields.Char(string='Confirmation 2')
s2a_confirm3 = fields.Char(string='Confirmation 3')
s2a_confirm4 = fields.Char(string='Confirmation 4')
s2a_confirm5 = fields.Char(string='Confirmation 5')
s2a_confirm6 = fields.Char(string='Confirmation 6')
# Prescription
s2a_seat_height = fields.Char(string='Seat Height')
s2a_seat_height_unit = fields.Char(string='Seat Height Unit')
s2a_handle_height = fields.Char(string='Push Handle Height')
s2a_handle_height_unit = fields.Char(string='Handle Height Unit')
s2a_hand_grips = fields.Char(string='Hand Grips')
s2a_forearm_attachments = fields.Char(string='Forearm Attachments')
s2a_width_handles = fields.Char(string='Width Between Push Handles')
s2a_width_handles_unit = fields.Char(string='Width Handles Unit')
s2a_client_weight = fields.Char(string='Client Weight')
s2a_client_weight_unit = fields.Char(string='Client Weight Unit')
s2a_brakes = fields.Char(string='Brakes')
s2a_brake_type = fields.Char(string='Brake Type')
s2a_num_wheels = fields.Char(string='Number of Wheels')
s2a_wheel_size = fields.Char(string='Wheel Size')
s2a_back_support = fields.Char(string='Back Support')
# ADP options
s2a_adp_walker = fields.Char(string='ADP Adolescent Walker')
s2a_adp_frame = fields.Char(string='ADP Adolescent Frame')
s2a_adp_standing = fields.Char(string='ADP Adolescent Standing')
# Custom modifications
s2a_custom = fields.Char(string='Custom Modifications Required')
s2a_cost_labour = fields.Char(string='Cost of Labour')
# ------------------------------------------------------------------
# SECTION 2B - MANUAL WHEELCHAIRS
# ------------------------------------------------------------------
s2b_base_device = fields.Char(string='Manual Wheelchair Type')
s2b_power_addon = fields.Char(string='Power Add-On Device')
s2b_reason = fields.Char(string='Reason for Application')
s2b_replacement_status = fields.Char(string='Replacement - Mobility Status')
s2b_replacement_size = fields.Char(string='Replacement - Body Size')
s2b_replacement_adp = fields.Char(string='Replacement - Equipment Worn')
s2b_replacement_special = fields.Char(string='Replacement - Special')
s2b_confirm1 = fields.Char(string='Confirmation 1')
s2b_confirm2 = fields.Char(string='Confirmation 2')
s2b_confirm3 = fields.Char(string='Confirmation 3')
s2b_confirm4 = fields.Char(string='Confirmation 4')
s2b_confirm5 = fields.Char(string='Confirmation 5')
s2b_confirm6 = fields.Char(string='Confirmation 6')
s2b_confirm7 = fields.Char(string='Confirmation 7')
s2b_confirm8 = fields.Char(string='Confirmation 8')
s2b_confirm9 = fields.Char(string='Confirmation 9')
s2b_confirm10 = fields.Char(string='Confirmation 10')
s2b_confirm11 = fields.Char(string='Confirmation 11')
s2b_confirm12 = fields.Char(string='Confirmation 12')
s2b_confirm13 = fields.Char(string='Confirmation 13')
# Prescription
s2b_seat_width = fields.Char(string='Seat Width')
s2b_seat_width_unit = fields.Char(string='Seat Width Unit')
s2b_seat_depth = fields.Char(string='Seat Depth')
s2b_seat_depth_unit = fields.Char(string='Seat Depth Unit')
s2b_floor_height = fields.Char(string='Finished Seat to Floor Height')
s2b_floor_height_unit = fields.Char(string='Floor Height Unit')
s2b_cane_height = fields.Char(string='Back Cane Height')
s2b_cane_height_unit = fields.Char(string='Cane Height Unit')
s2b_back_height = fields.Char(string='Finished Back Height')
s2b_back_height_unit = fields.Char(string='Back Height Unit')
s2b_rest_length = fields.Char(string='Finished Leg Rest Length')
s2b_rest_length_unit = fields.Char(string='Rest Length Unit')
s2b_client_weight = fields.Char(string='Client Weight')
s2b_client_weight_unit = fields.Char(string='Client Weight Unit')
# Add-on options
s2b_adjustable_tension = fields.Boolean(string='Adjustable Tension Back Upholstery')
s2b_heavy_duty = fields.Boolean(string='Heavy Duty Cross Braces & Upholstery')
s2b_recliner = fields.Boolean(string='Recliner Option')
s2b_footplates = fields.Boolean(string='Angle Adjustable Footplates')
s2b_legrests = fields.Boolean(string='Elevating Legrests')
s2b_spoke = fields.Boolean(string='Spoke Protectors')
s2b_projected = fields.Boolean(string='Projected Handrims')
s2b_standard_manual = fields.Boolean(string='Standard Manual with Dynamic Tilt')
s2b_grade_aids = fields.Boolean(string='Grade Aids')
s2b_caster_pin = fields.Boolean(string='Caster Pin Locks')
s2b_amputee_axle = fields.Boolean(string='Amputee Axle Plates')
s2b_quick_release = fields.Boolean(string='Quick Release Axles')
s2b_stroller = fields.Boolean(string='Stroller Handles/Paediatric')
s2b_oxygen = fields.Boolean(string='Oxygen Tank Holder')
s2b_ventilator = fields.Boolean(string='Ventilator Tray')
s2b_titanium = fields.Boolean(string='Titanium Frame')
s2b_clothing_guards = fields.Boolean(string='Clothing Guards')
s2b_one_arm = fields.Boolean(string='One Arm/Lever Drive')
s2b_uni_lateral = fields.Boolean(string='Uni-Lateral Wheel Lock')
s2b_plastic = fields.Boolean(string='Plastic Coated Handrims')
s2b_rationale = fields.Text(string='Clinical Rationale')
s2b_custom = fields.Char(string='Custom Modifications Required')
s2b_cost_labour = fields.Char(string='Cost of Labour')
# ------------------------------------------------------------------
# SECTION 2C - POWER BASES AND POWER SCOOTERS
# ------------------------------------------------------------------
s2c_base_device = fields.Char(string='Power Base/Scooter Type')
s2c_reason = fields.Char(string='Reason for Application')
s2c_replacement_status = fields.Char(string='Replacement - Mobility Status')
s2c_replacement_size = fields.Char(string='Replacement - Body Size')
s2c_replacement_adp = fields.Char(string='Replacement - Equipment Worn')
s2c_replacement_special = fields.Char(string='Replacement - Special')
s2c_confirm1 = fields.Char(string='Power Base Confirmation 1')
s2c_confirm2 = fields.Char(string='Power Base Confirmation 2')
s2c_confirm3 = fields.Char(string='Scooter Confirmation 1')
s2c_confirm4 = fields.Char(string='Scooter Confirmation 2')
s2c_confirm5 = fields.Char(string='Scooter Confirmation 3')
# Prescription
s2c_seat_width = fields.Char(string='Seat Width')
s2c_seat_width_unit = fields.Char(string='Seat Width Unit')
s2c_back_height = fields.Char(string='Finished Back Height')
s2c_back_height_unit = fields.Char(string='Back Height Unit')
s2c_floor_height = fields.Char(string='Finished Seat to Floor Height')
s2c_floor_height_unit = fields.Char(string='Floor Height Unit')
s2c_rest_length = fields.Char(string='Leg Rest Length')
s2c_rest_length_unit = fields.Char(string='Rest Length Unit')
s2c_seat_depth = fields.Char(string='Seat Depth')
s2c_seat_depth_unit = fields.Char(string='Seat Depth Unit')
s2c_client_weight = fields.Char(string='Client Weight')
s2c_client_weight_unit = fields.Char(string='Client Weight Unit')
# Add-on options
s2c_adjustable_tension = fields.Boolean(string='Adjustable Tension Back Upholstery')
s2c_midline = fields.Boolean(string='Midline Control')
s2c_manual_recline = fields.Boolean(string='Manual Recline Option')
s2c_footplates = fields.Boolean(string='Angle Adjustable Footplates')
s2c_legrests = fields.Boolean(string='Manual Elevating Legrests')
s2c_swingaway = fields.Boolean(string='Swingaway Mounting Bracket')
s2c_one_piece = fields.Boolean(string='One Piece 90/90 Front Riggings')
s2c_seat_package_1 = fields.Boolean(string='Seat Package 1 for Power Bases')
s2c_seat_package_2 = fields.Boolean(string='Seat Package 2 for Power Bases')
s2c_oxygen = fields.Boolean(string='Oxygen Tank Holder')
s2c_ventilator = fields.Boolean(string='Ventilator Tray')
# Specialty controls
s2c_sp_controls_1 = fields.Boolean(string='Specialty Controls 1 - Non Standard Joystick')
s2c_sp_controls_2 = fields.Boolean(string='Specialty Controls 2 - Chin/Rim Control')
s2c_sp_controls_3 = fields.Boolean(string='Specialty Controls 3 - Simple Touch')
s2c_sp_controls_4 = fields.Boolean(string='Specialty Controls 4 - Proximity Control')
s2c_sp_controls_5 = fields.Boolean(string='Specialty Controls 5 - Breath Control')
s2c_sp_controls_6 = fields.Boolean(string='Specialty Controls 6 - Scanners')
s2c_auto_correction = fields.Boolean(string='Auto Correction System')
s2c_rationale = fields.Text(string='Clinical Rationale')
# Power positioning
s2c_power_tilt = fields.Boolean(string='Power Tilt Only')
s2c_power_recline = fields.Boolean(string='Power Recline Only')
s2c_tilt_and_recline = fields.Boolean(string='Power Tilt and Recline')
s2c_power_elevating = fields.Boolean(string='Power Elevating Footrests')
s2c_control_box = fields.Boolean(string='Multi-Function Control Box')
s2c_custom = fields.Char(string='Custom Modifications Required')
s2c_cost_labour = fields.Char(string='Cost of Labour')
# ------------------------------------------------------------------
# SECTION 2D - POSITIONING DEVICES (SEATING) FOR MOBILITY
# ------------------------------------------------------------------
# Seat Cushion
s2d_seat_modular = fields.Boolean(string='Seat Cushion - Modular')
s2d_seat_custom = fields.Boolean(string='Seat Cushion - Custom Fabricated')
s2d_seat_cover_modular = fields.Boolean(string='Seat Cover - Modular')
s2d_seat_cover_custom = fields.Boolean(string='Seat Cover - Custom Fabricated')
s2d_seat_option_modular = fields.Boolean(string='Seat Options - Modular')
s2d_seat_option_custom = fields.Boolean(string='Seat Options - Custom Fabricated')
s2d_seat_hardware_modular = fields.Boolean(string='Seat Hardware - Modular')
s2d_seat_hardware_custom = fields.Boolean(string='Seat Hardware - Custom Fabricated')
s2d_adductor_modular = fields.Boolean(string='Pommel/Adductors - Modular')
s2d_adductor_custom = fields.Boolean(string='Pommel/Adductors - Custom Fabricated')
s2d_pommel_custom = fields.Boolean(string='Pommel Hardware - Custom Fabricated')
# Back Support
s2d_back_modular = fields.Boolean(string='Back Support - Modular')
s2d_back_custom = fields.Boolean(string='Back Support - Custom Fabricated')
s2d_back_option_modular = fields.Boolean(string='Back Options - Modular')
s2d_back_option_custom = fields.Boolean(string='Back Options - Custom Fabricated')
s2d_back_cover_custom = fields.Boolean(string='Back Cover - Custom Fabricated')
s2d_back_hardware_modular = fields.Boolean(string='Back Hardware - Modular')
s2d_back_hardware_custom = fields.Boolean(string='Back Hardware - Custom Fabricated')
# Complete Assembly
s2d_complete_modular = fields.Boolean(string='Complete Assembly - Modular')
s2d_complete_custom = fields.Boolean(string='Complete Assembly - Custom Fabricated')
# Headrest/Neckrest
s2d_headrest_modular = fields.Boolean(string='Headrest/Neckrest - Modular')
s2d_headrest_custom = fields.Boolean(string='Headrest/Neckrest - Custom Fabricated')
s2d_head_option_custom = fields.Boolean(string='Headrest Options - Custom Fabricated')
s2d_head_hardware_modular = fields.Boolean(string='Headrest Hardware - Modular')
s2d_head_hardware_custom = fields.Boolean(string='Headrest Hardware - Custom Fabricated')
# Positioning Belts
s2d_belt_modular = fields.Boolean(string='Positioning Belt - Modular')
s2d_belt_custom = fields.Boolean(string='Positioning Belt - Custom Fabricated')
s2d_belt_option_custom = fields.Boolean(string='Belt Options - Custom Fabricated')
# Arm Supports
s2d_arm_modular = fields.Boolean(string='Arm Support - Modular')
s2d_arm_custom = fields.Boolean(string='Arm Support - Custom Fabricated')
s2d_arm_option_modular = fields.Boolean(string='Arm Options - Modular')
s2d_arm_option_custom = fields.Boolean(string='Arm Options - Custom Fabricated')
s2d_arm_hardware_modular = fields.Boolean(string='Arm Hardware - Modular')
s2d_arm_hardware_custom = fields.Boolean(string='Arm Hardware - Custom Fabricated')
# Tray
s2d_tray_modular = fields.Boolean(string='Tray - Modular')
s2d_tray_custom = fields.Boolean(string='Tray - Custom Fabricated')
s2d_tray_option_modular = fields.Boolean(string='Tray Options - Modular')
s2d_tray_option_custom = fields.Boolean(string='Tray Options - Custom Fabricated')
# Lateral Supports
s2d_lateral_modular = fields.Boolean(string='Lateral Support - Modular')
s2d_lateral_custom = fields.Boolean(string='Lateral Support - Custom Fabricated')
s2d_lateral_option_custom = fields.Boolean(string='Lateral Options - Custom Fabricated')
s2d_lateral_hardware_custom = fields.Boolean(string='Lateral Hardware - Custom Fabricated')
# Foot/Leg Supports
s2d_foot_modular = fields.Boolean(string='Foot/Leg Support - Modular')
s2d_foot_custom = fields.Boolean(string='Foot/Leg Support - Custom Fabricated')
s2d_foot_option_modular = fields.Boolean(string='Foot Options - Modular')
s2d_foot_option_custom = fields.Boolean(string='Foot Options - Custom Fabricated')
s2d_foot_hardware_modular = fields.Boolean(string='Foot Hardware - Modular')
s2d_foot_hardware_custom = fields.Boolean(string='Foot Hardware - Custom Fabricated')
# Seating reason and confirmations
s2d_reason = fields.Char(string='Reason for Application')
s2d_replacement_status = fields.Char(string='Replacement - Mobility Status')
s2d_replacement_size = fields.Char(string='Replacement - Body Size')
s2d_replacement_adp = fields.Char(string='Replacement - Equipment Worn')
s2d_replacement_special = fields.Char(string='Replacement - Special')
s2d_confirm1 = fields.Char(string='Seating Confirmation 1')
s2d_confirm2 = fields.Char(string='Seating Confirmation 2')
s2d_custom = fields.Char(string='Custom Modifications Required')
s2d_cost_labour = fields.Char(string='Cost of Labour')
# ------------------------------------------------------------------
# SECTION 3 - APPLICANT CONSENT AND SIGNATURE
# ------------------------------------------------------------------
consent_date = fields.Date(string='Consent Date')
consent_signed_by = fields.Selection([
('applicant', 'Applicant'),
('agent', 'Agent'),
], string='Signed By')
# Agent/Contact info (if signed by agent)
agent_relationship = fields.Char(string='Agent Relationship')
agent_last_name = fields.Char(string='Agent Last Name')
agent_first_name = fields.Char(string='Agent First Name')
agent_middle_initial = fields.Char(string='Agent Middle Initial')
agent_unit = fields.Char(string='Agent Unit')
agent_street_no = fields.Char(string='Agent Street Number')
agent_street_name = fields.Char(string='Agent Street Name')
agent_rural_route = fields.Char(string='Agent Rural Route')
agent_city = fields.Char(string='Agent City')
agent_province = fields.Char(string='Agent Province')
agent_postal_code = fields.Char(string='Agent Postal Code')
agent_home_phone = fields.Char(string='Agent Home Phone')
agent_bus_phone = fields.Char(string='Agent Business Phone')
agent_phone_ext = fields.Char(string='Agent Phone Ext')
# ------------------------------------------------------------------
# SECTION 4 - AUTHORIZER
# ------------------------------------------------------------------
authorizer_last_name = fields.Char(string='Authorizer Last Name')
authorizer_first_name = fields.Char(string='Authorizer First Name')
authorizer_phone = fields.Char(string='Authorizer Phone')
authorizer_phone_ext = fields.Char(string='Authorizer Phone Ext')
authorizer_adp_number = fields.Char(string='Authorizer ADP Registration Number')
assessment_date = fields.Date(string='Assessment Date')
# ------------------------------------------------------------------
# SECTION 4 - VENDOR 1
# ------------------------------------------------------------------
vendor_business_name = fields.Char(string='Vendor Business Name')
vendor_adp_number = fields.Char(string='Vendor ADP Registration Number')
vendor_representative = fields.Char(string='Vendor Representative (Last, First)')
vendor_position = fields.Char(string='Vendor Position Title')
vendor_location = fields.Char(string='Vendor Location')
vendor_phone = fields.Char(string='Vendor Phone')
vendor_phone_ext = fields.Char(string='Vendor Phone Ext')
vendor_sign_date = fields.Date(string='Vendor Sign Date')
# ------------------------------------------------------------------
# SECTION 4 - VENDOR 2
# ------------------------------------------------------------------
vendor2_business_name = fields.Char(string='Vendor 2 Business Name')
vendor2_adp_number = fields.Char(string='Vendor 2 ADP Registration')
vendor2_representative = fields.Char(string='Vendor 2 Representative')
vendor2_position = fields.Char(string='Vendor 2 Position')
vendor2_location = fields.Char(string='Vendor 2 Location')
vendor2_phone = fields.Char(string='Vendor 2 Phone')
vendor2_phone_ext = fields.Char(string='Vendor 2 Phone Ext')
vendor2_sign_date = fields.Date(string='Vendor 2 Sign Date')
# ------------------------------------------------------------------
# SECTION 4 - EQUIPMENT SPEC & PROOF OF DELIVERY
# ------------------------------------------------------------------
equip_vendor_invoice_no = fields.Char(string='Vendor Invoice Number')
equip_vendor_adp_reg = fields.Char(string='Vendor ADP Reg (Page 12)')
equip_cell1 = fields.Char(string='ADP Device Code')
equip_cell2 = fields.Char(string='Description of Item')
equip_cell3 = fields.Char(string='Base Device')
equip_cell4 = fields.Char(string='ADP Portion')
equip_cell5 = fields.Char(string='Client Portion')
pod_received_by = fields.Char(string='Proof of Delivery - Received By')
pod_date = fields.Date(string='Proof of Delivery Date')
# ------------------------------------------------------------------
# SECTION 4 - NOTE TO ADP (sections submitted checklist)
# ------------------------------------------------------------------
note_section1 = fields.Boolean(string='Section 1 Submitted')
note_section2a = fields.Boolean(string='Section 2a Submitted')
note_section2b = fields.Boolean(string='Section 2b Submitted')
note_section2c = fields.Boolean(string='Section 2c Submitted')
note_section2d = fields.Boolean(string='Section 2d Submitted')
note_section3and4 = fields.Boolean(string='Section 3 & 4 Submitted')
note_vendor_replacement = fields.Char(string='Vendor Quote - Replacement')
note_vendor_custom = fields.Char(string='Vendor Quote - Custom Modifications')
note_funding_chart = fields.Char(string='Justification for Funding Chart')
note_letter = fields.Char(string='Letter of Rationale')
# Computed summary
sections_submitted = fields.Char(
string='Sections Submitted',
compute='_compute_sections_submitted', store=True,
)
# Legacy compat fields
base_device = fields.Char(
string='Base Device Selected',
compute='_compute_base_device', store=True,
)
reason_for_application = fields.Char(
string='Reason for Application',
compute='_compute_reason', store=True,
)
# ------------------------------------------------------------------
# COMPUTED
# ------------------------------------------------------------------
@api.depends('applicant_last_name', 'applicant_first_name', 'application_date')
def _compute_display_name(self):
for rec in self:
name_parts = [rec.applicant_last_name or '', rec.applicant_first_name or '']
name = ', '.join(p for p in name_parts if p) or 'Unknown'
date_str = rec.application_date.strftime('%Y-%m-%d') if rec.application_date else 'No Date'
rec.display_name = f'{name} ({date_str})'
@api.depends('note_section1', 'note_section2a', 'note_section2b',
'note_section2c', 'note_section2d', 'note_section3and4')
def _compute_sections_submitted(self):
for rec in self:
parts = []
if rec.note_section1:
parts.append('1')
if rec.note_section2a:
parts.append('2a')
if rec.note_section2b:
parts.append('2b')
if rec.note_section2c:
parts.append('2c')
if rec.note_section2d:
parts.append('2d')
if rec.note_section3and4:
parts.append('3+4')
rec.sections_submitted = ', '.join(parts) if parts else ''
@api.depends('s2a_base_device', 's2b_base_device', 's2c_base_device')
def _compute_base_device(self):
for rec in self:
rec.base_device = rec.s2a_base_device or rec.s2b_base_device or rec.s2c_base_device or ''
@api.depends('s2a_reason', 's2b_reason', 's2c_reason', 's2d_reason')
def _compute_reason(self):
for rec in self:
rec.reason_for_application = rec.s2a_reason or rec.s2b_reason or rec.s2c_reason or rec.s2d_reason or ''
# ------------------------------------------------------------------
# XML EXPORT
# ------------------------------------------------------------------
def action_export_xml(self):
"""Reconstruct ADP XML from stored JSON data."""
self.ensure_one()
if not self.xml_data_json:
# Fall back to raw_xml if available
if self.raw_xml:
xml_content = self.raw_xml.encode('utf-8')
attachment = self.env['ir.attachment'].create({
'name': f'{self.applicant_last_name}_{self.applicant_first_name}_data.xml',
'type': 'binary',
'datas': base64.b64encode(xml_content),
'mimetype': 'application/xml',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'new',
}
return False
try:
data = json.loads(self.xml_data_json)
xml_str = self._json_to_xml(data)
attachment = self.env['ir.attachment'].create({
'name': f'{self.applicant_last_name or "export"}_{self.applicant_first_name or "data"}_data.xml',
'type': 'binary',
'datas': base64.b64encode(xml_str.encode('utf-8')),
'mimetype': 'application/xml',
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'new',
}
except Exception as e:
_logger.exception('XML export error: %s', e)
return False
def _json_to_xml(self, data):
"""Reconstruct the ADP XML from flat JSON dictionary."""
# Build the XML tree following the exact ADP structure
root = ET.Element('form1')
form = ET.SubElement(root, 'Form')
# Simple top-level fields
self._set_el(form, 'deviceCategory', data.get('deviceCategory', ''))
self._set_el(form, 'VersionNumber', data.get('VersionNumber', ''))
# Section 1
s1 = ET.SubElement(form, 'section1')
s1_fields = [
'applicantLastname', 'applicantFirstname', 'applicantMiddleinitial',
'healthNo', 'versionNo', 'DateOfBirth', 'nameLTCH',
'unitNo', 'streetNo', 'streetName', 'rrRoute',
'city', 'province', 'postalCode',
'homePhone', 'busPhone', 'phoneExtension',
]
for f in s1_fields:
self._set_el(s1, f, data.get(f'section1.{f}', ''))
# Confirmation of benefit
cob = ET.SubElement(s1, 'confirmationOfBenefit')
for f in ['q1Yn', 'q1Ifyes', 'q2Yn', 'q3Yn']:
self._set_el(cob, f, data.get(f'section1.confirmationOfBenefit.{f}', ''))
# Section 2
s2 = ET.SubElement(form, 'section2')
# Devices and Eligibility
de = ET.SubElement(s2, 'devicesandEligibility')
de_fields = [
'condition', 'status', 'none', 'forearm', 'wheeled', 'manual',
'power', 'addOn', 'scooter', 'seating', 'tiltSystem', 'reclineSystem',
'legRests', 'frame', 'stroller', 'deviceForearm', 'deviceWheeled',
'deviceManual', 'deviceAmbulation', 'deviceDependent', 'deviceDynamic',
'manualDyanmic', 'manualWheelchair', 'powerBase', 'powerScooter',
'ambulation', 'positioning', 'highTech', 'standingFrame',
'adpFunded', 'nonADPFunded',
]
for f in de_fields:
self._set_el(de, f, data.get(f'section2.devicesandEligibility.{f}', ''))
# Sections 2a, 2b, 2c, 2d
for section_key in ['section2a', 'section2b', 'section2c', 'section2d']:
section_data = {k.split(f'section2.{section_key}.')[1]: v
for k, v in data.items()
if k.startswith(f'section2.{section_key}.')}
sec = ET.SubElement(s2, section_key)
# Preserve field order from the data keys
ordered_keys = [k.split(f'section2.{section_key}.')[1]
for k in sorted(data.keys())
if k.startswith(f'section2.{section_key}.')]
for f in ordered_keys:
self._set_el(sec, f, section_data.get(f, ''))
# Section 3
s3 = ET.SubElement(form, 'section3')
sig = ET.SubElement(s3, 'sig')
for f in ['signature', 'person', 'Date']:
self._set_el(sig, f, data.get(f'section3.sig.{f}', ''))
contact = ET.SubElement(s3, 'contact')
contact_fields = [
'relationship', 'applicantLastname', 'applicantFirstname',
'applicantMiddleinitial', 'unitNo', 'streetNo', 'streetName',
'rrRoute', 'city', 'province', 'postalCode',
'homePhone', 'busPhone', 'phoneExtension',
]
for f in contact_fields:
self._set_el(contact, f, data.get(f'section3.contact.{f}', ''))
# Section 4
s4 = ET.SubElement(form, 'section4')
# Authorizer
auth = ET.SubElement(s4, 'authorizer')
for f in ['authorizerLastname', 'authorizerFirstname', 'busPhone',
'phoneExtension', 'adpNo', 'signature', 'Date']:
self._set_el(auth, f, data.get(f'section4.authorizer.{f}', ''))
# Vendor
vendor = ET.SubElement(s4, 'vendor')
for f in ['vendorBusName', 'adpVendorRegNo', 'vendorLastfirstname',
'positionTitle', 'vendorLocation', 'busPhone',
'phoneExtension', 'signature', 'Date']:
self._set_el(vendor, f, data.get(f'section4.vendor.{f}', ''))
# Vendor 2
v2 = ET.SubElement(s4, 'vendor2')
for f in ['vendorBusName', 'adpVendorRegNo', 'vendorLastfirstname',
'positionTitle', 'vendorLocation', 'busPhone',
'phoneExtension', 'signature', 'Date']:
self._set_el(v2, f, data.get(f'section4.vendor2.{f}', ''))
# Equipment Spec
eq = ET.SubElement(s4, 'equipmentSpec')
self._set_el(eq, 'vendorInvoiceNo', data.get('section4.equipmentSpec.vendorInvoiceNo', ''))
self._set_el(eq, 'vendorADPRegNo', data.get('section4.equipmentSpec.vendorADPRegNo', ''))
t2 = ET.SubElement(eq, 'Table2')
r1 = ET.SubElement(t2, 'Row1')
for c in ['Cell1', 'Cell2', 'Cell3', 'Cell4', 'Cell5']:
self._set_el(r1, c, data.get(f'section4.equipmentSpec.Table2.Row1.{c}', ''))
# Proof of delivery
pod = ET.SubElement(s4, 'proofOfDelivery')
for f in ['signature', 'receivedBy', 'Date']:
self._set_el(pod, f, data.get(f'section4.proofOfDelivery.{f}', ''))
# Note to ADP
note = ET.SubElement(s4, 'noteToADP')
for f in ['section1', 'section2a', 'section2b', 'section2c', 'section2d',
'section3and4', 'vendorReplacement', 'vendorCustom',
'fundingChart', 'letter']:
self._set_el(note, f, data.get(f'section4.noteToADP.{f}', ''))
# Convert to string
tree = ET.ElementTree(root)
ET.indent(tree, space='')
import io
buf = io.BytesIO()
tree.write(buf, encoding='unicode', xml_declaration=True)
return buf.getvalue()
@staticmethod
def _set_el(parent, tag, value):
"""Create a child element, self-closing if empty."""
el = ET.SubElement(parent, tag)
if value:
el.text = str(value)

View File

@@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
import logging
from datetime import date, timedelta
from odoo import models, api
_logger = logging.getLogger(__name__)
class ADPPostingScheduleMixin(models.AbstractModel):
"""Mixin providing ADP posting schedule calculation methods.
This mixin can be inherited by any model that needs to calculate
ADP posting dates and deadlines.
Posting Schedule Logic:
- Posting days occur every N days (default 14) from a base date
- Submission deadline: Wednesday 6 PM before posting day
- Delivery reminder: Tuesday of posting week
- Billing reminder: Monday of posting week
- Payment processed: Posting day + 7 days
- Payment received: Posting day + 10 days
"""
_name = 'fusion_claims.adp.posting.schedule.mixin'
_description = 'ADP Posting Schedule Mixin'
@api.model
def _get_adp_posting_base_date(self):
"""Get the configured base posting date from settings."""
ICP = self.env['ir.config_parameter'].sudo()
base_date_str = ICP.get_param('fusion_claims.adp_posting_base_date', '2026-01-23')
try:
return date.fromisoformat(base_date_str)
except (ValueError, TypeError):
return date(2026, 1, 23)
@api.model
def _get_adp_posting_frequency(self):
"""Get the configured posting frequency in days from settings."""
ICP = self.env['ir.config_parameter'].sudo()
frequency = ICP.get_param('fusion_claims.adp_posting_frequency_days', '14')
try:
return int(frequency)
except (ValueError, TypeError):
return 14
@api.model
def _get_next_posting_date(self, from_date=None):
"""Calculate the next ADP posting date from a given date.
Args:
from_date: The date to calculate from (default: today)
Returns:
date: The next posting date
"""
if from_date is None:
from_date = date.today()
elif hasattr(from_date, 'date'):
from_date = from_date.date()
base_date = self._get_adp_posting_base_date()
frequency = self._get_adp_posting_frequency()
if frequency <= 0:
frequency = 14
# Calculate days since base date
days_diff = (from_date - base_date).days
if days_diff < 0:
# from_date is before base_date, so base_date is the next posting date
return base_date
# Calculate how many complete cycles have passed
cycles_passed = days_diff // frequency
# The next posting date is (cycles_passed + 1) * frequency days from base
next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency)
# If from_date equals a posting date, return the next one
if days_diff % frequency == 0:
return next_posting
return next_posting
@api.model
def _get_current_posting_date(self, from_date=None):
"""Get the posting date for the current cycle (may be in the past).
Args:
from_date: The date to calculate from (default: today)
Returns:
date: The current cycle's posting date
"""
if from_date is None:
from_date = date.today()
elif hasattr(from_date, 'date'):
from_date = from_date.date()
base_date = self._get_adp_posting_base_date()
frequency = self._get_adp_posting_frequency()
if frequency <= 0:
frequency = 14
days_diff = (from_date - base_date).days
if days_diff < 0:
return base_date
cycles_passed = days_diff // frequency
return base_date + timedelta(days=cycles_passed * frequency)
@api.model
def _get_posting_week_wednesday(self, posting_date):
"""Get the Wednesday before the posting date (submission deadline day).
The submission deadline is Wednesday 6 PM of the posting week.
Posting day is typically Friday, so Wednesday is 2 days before.
Args:
posting_date: The posting date
Returns:
date: The Wednesday before posting date
"""
if hasattr(posting_date, 'date'):
posting_date = posting_date.date()
# Find the Wednesday of the same week
# weekday(): Monday=0, Tuesday=1, Wednesday=2, Thursday=3, Friday=4
days_since_wednesday = (posting_date.weekday() - 2) % 7
if days_since_wednesday == 0 and posting_date.weekday() != 2:
days_since_wednesday = 7
return posting_date - timedelta(days=days_since_wednesday)
@api.model
def _get_posting_week_tuesday(self, posting_date):
"""Get the Tuesday of the posting week (delivery reminder date).
Args:
posting_date: The posting date
Returns:
date: The Tuesday of posting week
"""
if hasattr(posting_date, 'date'):
posting_date = posting_date.date()
# Find the Tuesday of the same week
days_since_tuesday = (posting_date.weekday() - 1) % 7
if days_since_tuesday == 0 and posting_date.weekday() != 1:
days_since_tuesday = 7
return posting_date - timedelta(days=days_since_tuesday)
@api.model
def _get_posting_week_monday(self, posting_date):
"""Get the Monday of the posting week (billing reminder date).
Args:
posting_date: The posting date
Returns:
date: The Monday of posting week
"""
if hasattr(posting_date, 'date'):
posting_date = posting_date.date()
# Find the Monday of the same week
days_since_monday = posting_date.weekday() # Monday=0
return posting_date - timedelta(days=days_since_monday)
@api.model
def _get_expected_payment_date(self, posting_date):
"""Get the expected payment received date (posting + 10 days).
Args:
posting_date: The posting date
Returns:
date: The expected payment received date
"""
if hasattr(posting_date, 'date'):
posting_date = posting_date.date()
return posting_date + timedelta(days=10)
@api.model
def _get_payment_processed_date(self, posting_date):
"""Get the payment processed date (posting + 7 days).
Args:
posting_date: The posting date
Returns:
date: The payment processed date
"""
if hasattr(posting_date, 'date'):
posting_date = posting_date.date()
return posting_date + timedelta(days=7)
@api.model
def _get_adp_billing_reminder_user(self):
"""Get the configured billing reminder user from settings."""
ICP = self.env['ir.config_parameter'].sudo()
user_id_str = ICP.get_param('fusion_claims.adp_billing_reminder_user_id', '')
if user_id_str:
try:
user_id = int(user_id_str)
return self.env['res.users'].browse(user_id).exists()
except (ValueError, TypeError):
pass
return self.env['res.users']
@api.model
def _get_adp_correction_reminder_users(self):
"""Get the configured correction reminder users from settings."""
ICP = self.env['ir.config_parameter'].sudo()
user_ids_str = ICP.get_param('fusion_claims.adp_correction_reminder_user_ids', '')
if user_ids_str:
try:
user_ids = [int(x.strip()) for x in user_ids_str.split(',') if x.strip()]
return self.env['res.users'].browse(user_ids).exists()
except (ValueError, TypeError):
pass
return self.env['res.users']
@api.model
def _is_past_submission_deadline(self, posting_date=None, check_time=True):
"""Check if we're past the submission deadline for a posting cycle.
Args:
posting_date: The posting date to check (default: next posting date)
check_time: If True, checks if past 6 PM on Wednesday
Returns:
bool: True if past deadline
"""
from datetime import datetime
if posting_date is None:
posting_date = self._get_next_posting_date()
wednesday = self._get_posting_week_wednesday(posting_date)
today = date.today()
if today > wednesday:
return True
elif today == wednesday and check_time:
# Check if past 6 PM (18:00)
now = datetime.now()
return now.hour >= 18
return False

View File

@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class AIAgentFusionClaims(models.Model):
"""Extend ai.agent with Fusion Claims tool methods."""
_inherit = 'ai.agent'
def _fc_tool_search_clients(self, search_term=None, city_filter=None, condition_filter=None):
"""AI Tool: Search client profiles."""
Profile = self.env['fusion.client.profile'].sudo()
domain = []
if search_term:
domain = ['|', '|', '|',
('first_name', 'ilike', search_term),
('last_name', 'ilike', search_term),
('health_card_number', 'ilike', search_term),
('city', 'ilike', search_term),
]
if city_filter:
domain.append(('city', 'ilike', city_filter))
if condition_filter:
domain.append(('medical_condition', 'ilike', condition_filter))
profiles = Profile.search(domain, limit=20)
results = []
for p in profiles:
results.append({
'id': p.id,
'name': p.display_name,
'health_card': p.health_card_number or '',
'dob': str(p.date_of_birth) if p.date_of_birth else '',
'city': p.city or '',
'condition': (p.medical_condition or '')[:100],
'claims': p.claim_count,
'total_adp': float(p.total_adp_funded),
'total_client': float(p.total_client_portion),
})
return json.dumps({'count': len(results), 'profiles': results})
def _fc_tool_client_details(self, profile_id):
"""AI Tool: Get detailed client information."""
Profile = self.env['fusion.client.profile'].sudo()
profile = Profile.browse(int(profile_id))
if not profile.exists():
return json.dumps({'error': 'Profile not found'})
# Get orders
orders = []
if profile.partner_id:
for o in self.env['sale.order'].sudo().search([
('partner_id', '=', profile.partner_id.id),
('x_fc_sale_type', '!=', False),
], limit=20):
orders.append({
'name': o.name,
'sale_type': o.x_fc_sale_type,
'status': o.x_fc_adp_application_status or '',
'adp_total': float(o.x_fc_adp_portion_total),
'client_total': float(o.x_fc_client_portion_total),
'total': float(o.amount_total),
'date': str(o.date_order.date()) if o.date_order else '',
})
# Get applications
apps = []
for a in profile.application_data_ids[:10]:
apps.append({
'date': str(a.application_date) if a.application_date else '',
'device': a.base_device or '',
'category': a.device_category or '',
'reason': a.reason_for_application or '',
'condition': (a.medical_condition or '')[:100],
'authorizer': f'{a.authorizer_first_name or ""} {a.authorizer_last_name or ""}'.strip(),
})
return json.dumps({
'profile': {
'id': profile.id,
'name': profile.display_name,
'first_name': profile.first_name,
'last_name': profile.last_name,
'health_card': profile.health_card_number or '',
'dob': str(profile.date_of_birth) if profile.date_of_birth else '',
'city': profile.city or '',
'province': profile.province or '',
'postal_code': profile.postal_code or '',
'phone': profile.home_phone or '',
'condition': profile.medical_condition or '',
'mobility': profile.mobility_status or '',
'benefits': {
'social_assistance': profile.receives_social_assistance,
'type': profile.benefit_type or '',
'wsib': profile.wsib_eligible,
'vac': profile.vac_eligible,
},
'claims_count': profile.claim_count,
'total_adp': float(profile.total_adp_funded),
'total_client': float(profile.total_client_portion),
'total_amount': float(profile.total_amount),
'last_assessment': str(profile.last_assessment_date) if profile.last_assessment_date else '',
},
'orders': orders,
'applications': apps,
})
def _fc_tool_claims_stats(self):
"""AI Tool: Get aggregated claims statistics."""
SO = self.env['sale.order'].sudo()
Profile = self.env['fusion.client.profile'].sudo()
total_profiles = Profile.search_count([])
total_orders = SO.search_count([('x_fc_sale_type', '!=', False)])
# By sale type
type_data = SO.read_group(
[('x_fc_sale_type', '!=', False)],
['x_fc_sale_type', 'amount_total:sum'],
['x_fc_sale_type'],
)
by_type = {}
for r in type_data:
by_type[r['x_fc_sale_type']] = {
'count': r['x_fc_sale_type_count'],
'total': float(r['amount_total'] or 0),
}
# By status
status_data = SO.read_group(
[('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)],
['x_fc_adp_application_status'],
['x_fc_adp_application_status'],
)
by_status = {}
for r in status_data:
by_status[r['x_fc_adp_application_status']] = r['x_fc_adp_application_status_count']
# By city (top 10)
city_data = Profile.read_group(
[('city', '!=', False)],
['city'],
['city'],
limit=10,
orderby='city_count desc',
)
by_city = {}
for r in city_data:
by_city[r['city']] = r['city_count']
return json.dumps({
'total_profiles': total_profiles,
'total_orders': total_orders,
'by_sale_type': by_type,
'by_status': by_status,
'top_cities': by_city,
})

View File

@@ -0,0 +1,350 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionClientChatSession(models.Model):
_name = 'fusion.client.chat.session'
_description = 'Client Intelligence Chat Session'
_order = 'create_date desc'
name = fields.Char(string='Session Title', required=True,
default=lambda self: f'Chat - {fields.Date.today()}')
profile_id = fields.Many2one(
'fusion.client.profile', string='Client Profile',
ondelete='set null',
help='If set, chat is scoped to this specific client',
)
user_id = fields.Many2one(
'res.users', string='User', default=lambda self: self.env.user,
required=True,
)
message_ids = fields.One2many(
'fusion.client.chat.message', 'session_id', string='Messages',
)
state = fields.Selection([
('active', 'Active'),
('archived', 'Archived'),
], default='active', string='State')
# Input field for the form view
user_input = fields.Text(string='Your Question')
def action_send_message(self):
"""Process user message and generate AI response."""
self.ensure_one()
if not self.user_input or not self.user_input.strip():
return
question = self.user_input.strip()
# Create user message
self.env['fusion.client.chat.message'].create({
'session_id': self.id,
'role': 'user',
'content': question,
})
# Generate AI response
try:
response = self._generate_ai_response(question)
except Exception as e:
_logger.exception('AI chat error: %s', e)
response = f'Sorry, I encountered an error processing your question. Error: {str(e)}'
# Create assistant message
msg = self.env['fusion.client.chat.message'].create({
'session_id': self.id,
'role': 'assistant',
'content': response,
})
# Clear input
self.user_input = False
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.client.chat.session',
'view_mode': 'form',
'res_id': self.id,
'target': 'current',
}
def _generate_ai_response(self, question):
"""Generate an AI-powered response to the user question.
Uses OpenAI API to analyze the question, query relevant data,
and formulate a response.
"""
ICP = self.env['ir.config_parameter'].sudo()
api_key = ICP.get_param('fusion_claims.ai_api_key', '')
if not api_key:
return self._generate_local_response(question)
ai_model = ICP.get_param('fusion_claims.ai_model', 'gpt-4o-mini')
# Build context about available data
context_data = self._build_data_context(question)
# Build system prompt
system_prompt = self._build_system_prompt()
# Build messages
messages = [{'role': 'system', 'content': system_prompt}]
# Add conversation history (last 10 messages)
history = self.message_ids.sorted('create_date')[-10:]
for msg in history:
messages.append({'role': msg.role, 'content': msg.content})
# Add current question with data context
user_msg = question
if context_data:
user_msg += f'\n\n--- Retrieved Data ---\n{context_data}'
messages.append({'role': 'user', 'content': user_msg})
# Call OpenAI API
try:
import requests
response = requests.post(
'https://api.openai.com/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': ai_model,
'messages': messages,
'max_tokens': 2000,
'temperature': 0.3,
},
timeout=30,
)
response.raise_for_status()
result = response.json()
return result['choices'][0]['message']['content']
except ImportError:
return self._generate_local_response(question)
except Exception as e:
_logger.warning('OpenAI API error: %s', e)
return self._generate_local_response(question)
def _generate_local_response(self, question):
"""Generate a response without AI, using direct database queries.
This is the fallback when no API key is configured.
"""
question_lower = question.lower()
Profile = self.env['fusion.client.profile']
SaleOrder = self.env['sale.order']
# If scoped to a specific profile
if self.profile_id:
profile = self.profile_id
orders = SaleOrder.search([
('partner_id', '=', profile.partner_id.id),
('x_fc_sale_type', '!=', False),
]) if profile.partner_id else SaleOrder
lines = []
lines.append(f'**Client: {profile.display_name}**')
lines.append(f'- Health Card: {profile.health_card_number or "N/A"}')
lines.append(f'- Date of Birth: {profile.date_of_birth or "N/A"}')
lines.append(f'- City: {profile.city or "N/A"}')
lines.append(f'- Medical Condition: {profile.medical_condition or "N/A"}')
lines.append(f'- Mobility Status: {profile.mobility_status or "N/A"}')
lines.append(f'- Total Claims: {len(orders)}')
lines.append(f'- Total ADP Funded: ${profile.total_adp_funded:,.2f}')
lines.append(f'- Total Client Portion: ${profile.total_client_portion:,.2f}')
if orders:
lines.append('\n**Claims History:**')
for order in orders[:10]:
status = dict(order._fields['x_fc_adp_application_status'].selection).get(
order.x_fc_adp_application_status, order.x_fc_adp_application_status or 'N/A'
)
lines.append(
f'- {order.name}: {order.x_fc_sale_type or "N/A"} | '
f'Status: {status} | '
f'ADP: ${order.x_fc_adp_portion_total:,.2f} | '
f'Client: ${order.x_fc_client_portion_total:,.2f}'
)
apps = profile.application_data_ids[:5]
if apps:
lines.append('\n**Application History:**')
for app in apps:
lines.append(
f'- {app.application_date or "No date"}: '
f'{app.device_category or "N/A"} | '
f'Device: {app.base_device or "N/A"} | '
f'Reason: {app.reason_for_application or "N/A"}'
)
return '\n'.join(lines)
# Global queries
total_profiles = Profile.search_count([])
total_orders = SaleOrder.search_count([('x_fc_sale_type', '!=', False)])
if 'how many' in question_lower or 'count' in question_lower:
if 'client' in question_lower or 'profile' in question_lower:
return f'There are currently **{total_profiles}** client profiles in the system.'
if 'claim' in question_lower or 'order' in question_lower or 'case' in question_lower:
return f'There are currently **{total_orders}** claims/orders in the system.'
# Search for specific client
if 'find' in question_lower or 'search' in question_lower or 'show' in question_lower:
# Try to extract name from question
words = question.split()
profiles = Profile.search([], limit=20)
for word in words:
if len(word) > 2 and word[0].isupper():
found = Profile.search([
'|',
('first_name', 'ilike', word),
('last_name', 'ilike', word),
], limit=5)
if found:
profiles = found
break
if profiles:
lines = [f'Found **{len(profiles)}** matching profile(s):']
for p in profiles[:10]:
lines.append(
f'- **{p.display_name}** | HC: {p.health_card_number or "N/A"} | '
f'City: {p.city or "N/A"} | Claims: {p.claim_count}'
)
return '\n'.join(lines)
return (
f'I have access to **{total_profiles}** client profiles and **{total_orders}** claims. '
f'You can ask me questions like:\n'
f'- "How many clients are from Brampton?"\n'
f'- "Find client Raymond Wellesley"\n'
f'- "Show all clients with CVA diagnosis"\n\n'
f'For more intelligent responses, configure an OpenAI API key in '
f'Fusion Claims > Configuration > Settings.'
)
def _build_system_prompt(self):
"""Build the system prompt for the AI."""
profile_context = ''
if self.profile_id:
p = self.profile_id
profile_context = f"""
You are currently looking at a specific client profile:
- Name: {p.display_name}
- Health Card: {p.health_card_number or 'N/A'}
- DOB: {p.date_of_birth or 'N/A'}
- City: {p.city or 'N/A'}
- Medical Condition: {p.medical_condition or 'N/A'}
- Mobility Status: {p.mobility_status or 'N/A'}
- Total Claims: {p.claim_count}
- Total ADP Funded: ${p.total_adp_funded:,.2f}
- Total Client Portion: ${p.total_client_portion:,.2f}
"""
return f"""You are a helpful AI assistant for Fusion Claims, a healthcare equipment claims management system.
You help users find information about clients, their ADP (Assistive Devices Program) claims, funding history,
medical conditions, and devices.
Available data includes:
- Client profiles with personal info, health card numbers, addresses, medical conditions
- ADP application data parsed from XML submissions
- Sale orders with funding type, status, ADP/client portions
- Device information (wheelchairs, walkers, power bases, seating)
Funding types: ADP, ODSP, WSIB, March of Dimes, Muscular Dystrophy, Insurance, Hardship Funding, Rentals, Direct/Private
Client types: REG (75%/25%), ODS, OWP, ACS, LTC, SEN, CCA (100%/0%)
{profile_context}
Answer concisely and include specific data when available. Format monetary values with $ and commas."""
def _build_data_context(self, question):
"""Query relevant data based on the question to provide context to AI."""
question_lower = question.lower()
context_parts = []
Profile = self.env['fusion.client.profile']
SaleOrder = self.env['sale.order']
if self.profile_id:
# Scoped to specific client - load their data
p = self.profile_id
orders = SaleOrder.search([
('partner_id', '=', p.partner_id.id),
('x_fc_sale_type', '!=', False),
], limit=20) if p.partner_id else SaleOrder
if orders:
order_data = []
for o in orders:
order_data.append({
'name': o.name,
'sale_type': o.x_fc_sale_type,
'status': o.x_fc_adp_application_status,
'adp_total': o.x_fc_adp_portion_total,
'client_total': o.x_fc_client_portion_total,
'amount_total': o.amount_total,
'date': str(o.date_order) if o.date_order else '',
})
context_parts.append(f'Orders: {json.dumps(order_data)}')
apps = p.application_data_ids[:10]
if apps:
app_data = []
for a in apps:
app_data.append({
'date': str(a.application_date) if a.application_date else '',
'device_category': a.device_category,
'base_device': a.base_device or '',
'condition': a.medical_condition or '',
'reason': a.reason_for_application or '',
'authorizer': f'{a.authorizer_first_name} {a.authorizer_last_name}'.strip(),
})
context_parts.append(f'Applications: {json.dumps(app_data)}')
else:
# Global query - provide summary stats
total_profiles = Profile.search_count([])
total_orders = SaleOrder.search_count([('x_fc_sale_type', '!=', False)])
# City distribution
if 'city' in question_lower or 'cities' in question_lower or 'where' in question_lower:
city_data = SaleOrder.read_group(
[('x_fc_sale_type', '!=', False), ('partner_id.city', '!=', False)],
['partner_id'],
['partner_id'],
limit=20,
)
context_parts.append(f'Total profiles: {total_profiles}, Total orders: {total_orders}')
context_parts.append(f'Summary: {total_profiles} profiles, {total_orders} orders')
return '\n'.join(context_parts) if context_parts else ''
class FusionClientChatMessage(models.Model):
_name = 'fusion.client.chat.message'
_description = 'Chat Message'
_order = 'create_date asc'
session_id = fields.Many2one(
'fusion.client.chat.session', string='Session',
required=True, ondelete='cascade',
)
role = fields.Selection([
('user', 'User'),
('assistant', 'Assistant'),
], string='Role', required=True)
content = fields.Text(string='Content', required=True)
timestamp = fields.Datetime(
string='Timestamp', default=fields.Datetime.now,
)

View File

@@ -0,0 +1,298 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FusionClientProfile(models.Model):
_name = 'fusion.client.profile'
_description = 'Client Profile'
_order = 'last_name, first_name'
_rec_name = 'display_name'
_inherit = ['mail.thread', 'mail.activity.mixin']
# ------------------------------------------------------------------
# PERSONAL INFORMATION (from ADP XML Section 1)
# ------------------------------------------------------------------
partner_id = fields.Many2one(
'res.partner', string='Odoo Contact',
help='Linked contact record in Odoo',
)
first_name = fields.Char(string='First Name', tracking=True)
last_name = fields.Char(string='Last Name', tracking=True)
middle_initial = fields.Char(string='Middle Initial')
display_name = fields.Char(
string='Name', compute='_compute_display_name', store=True,
)
health_card_number = fields.Char(
string='Health Card Number', index=True, tracking=True,
help='Ontario Health Card Number (10 digits)',
)
health_card_version = fields.Char(string='Health Card Version')
date_of_birth = fields.Date(string='Date of Birth', tracking=True)
ltch_name = fields.Char(
string='Long-Term Care Home',
help='Name of LTCH if applicable',
)
# ------------------------------------------------------------------
# ADDRESS
# ------------------------------------------------------------------
unit_number = fields.Char(string='Unit Number')
street_number = fields.Char(string='Street Number')
street_name = fields.Char(string='Street Name')
rural_route = fields.Char(string='Lot/Concession/Rural Route')
city = fields.Char(string='City', index=True, tracking=True)
province = fields.Char(string='Province', default='ON')
postal_code = fields.Char(string='Postal Code')
# ------------------------------------------------------------------
# CONTACT
# ------------------------------------------------------------------
home_phone = fields.Char(string='Home Phone')
business_phone = fields.Char(string='Business Phone')
phone_extension = fields.Char(string='Phone Extension')
# ------------------------------------------------------------------
# BENEFITS ELIGIBILITY (from XML confirmationOfBenefit)
# ------------------------------------------------------------------
receives_social_assistance = fields.Boolean(
string='Receives Social Assistance', tracking=True,
)
benefit_type = fields.Selection([
('owp', 'Ontario Works Program (OWP)'),
('odsp', 'Ontario Disability Support Program (ODSP)'),
('acsd', 'Assistance to Children with Severe Disabilities (ACSD)'),
], string='Benefit Type', tracking=True)
wsib_eligible = fields.Boolean(string='WSIB Eligible', tracking=True)
vac_eligible = fields.Boolean(
string='Veterans Affairs Canada Eligible', tracking=True,
)
# ------------------------------------------------------------------
# CURRENT MEDICAL STATUS (updated from latest XML)
# ------------------------------------------------------------------
medical_condition = fields.Text(
string='Medical Condition/Diagnosis', tracking=True,
help='Current presenting medical condition from latest ADP application',
)
mobility_status = fields.Text(
string='Functional Mobility Status', tracking=True,
help='Current functional mobility status from latest ADP application',
)
last_assessment_date = fields.Date(
string='Last Assessment Date', tracking=True,
)
# ------------------------------------------------------------------
# RELATIONSHIPS
# ------------------------------------------------------------------
application_data_ids = fields.One2many(
'fusion.adp.application.data', 'profile_id',
string='ADP Applications',
)
# Chat is handled via Odoo's native AI agent (discuss.channel with ai_chat type)
# ------------------------------------------------------------------
# COMPUTED FIELDS
# ------------------------------------------------------------------
claim_count = fields.Integer(
string='Claims', compute='_compute_claim_stats', store=True,
)
total_adp_funded = fields.Monetary(
string='Total ADP Funded', compute='_compute_claim_stats', store=True,
currency_field='currency_id',
)
total_client_portion = fields.Monetary(
string='Total Client Portion', compute='_compute_claim_stats', store=True,
currency_field='currency_id',
)
total_amount = fields.Monetary(
string='Total Amount', compute='_compute_claim_stats', store=True,
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
application_count = fields.Integer(
string='Applications', compute='_compute_application_count',
)
# ------------------------------------------------------------------
# AI ANALYSIS (auto-computed from application data)
# ------------------------------------------------------------------
ai_summary = fields.Text(
string='Summary',
compute='_compute_ai_analysis',
)
ai_risk_flags = fields.Text(
string='Risk Flags',
compute='_compute_ai_analysis',
)
ai_last_analyzed = fields.Datetime(string='Last AI Analysis')
# ------------------------------------------------------------------
# COMPUTED METHODS
# ------------------------------------------------------------------
@api.depends('first_name', 'last_name')
def _compute_display_name(self):
for profile in self:
parts = [profile.last_name or '', profile.first_name or '']
profile.display_name = ', '.join(p for p in parts if p) or 'New Profile'
@api.depends('partner_id', 'partner_id.sale_order_ids',
'partner_id.sale_order_ids.x_fc_adp_portion_total',
'partner_id.sale_order_ids.x_fc_client_portion_total',
'partner_id.sale_order_ids.amount_total')
def _compute_claim_stats(self):
for profile in self:
if profile.partner_id:
orders = self.env['sale.order'].search([
('partner_id', '=', profile.partner_id.id),
('x_fc_sale_type', '!=', False),
])
profile.claim_count = len(orders)
profile.total_adp_funded = sum(orders.mapped('x_fc_adp_portion_total'))
profile.total_client_portion = sum(orders.mapped('x_fc_client_portion_total'))
profile.total_amount = sum(orders.mapped('amount_total'))
else:
profile.claim_count = 0
profile.total_adp_funded = 0
profile.total_client_portion = 0
profile.total_amount = 0
def _compute_application_count(self):
for profile in self:
profile.application_count = len(profile.application_data_ids)
@api.depends('application_data_ids', 'application_data_ids.application_date',
'application_data_ids.base_device', 'application_data_ids.reason_for_application')
def _compute_ai_analysis(self):
for profile in self:
apps = profile.application_data_ids.sorted('application_date', reverse=True)
# --- SUMMARY ---
summary_lines = []
# Number of applications
app_count = len(apps)
summary_lines.append(f"Total Applications: {app_count}")
# Last funding history
if apps:
latest = apps[0]
date_str = latest.application_date.strftime('%B %d, %Y') if latest.application_date else 'Unknown date'
device = latest.base_device or 'Not specified'
category = dict(latest._fields['device_category'].selection).get(
latest.device_category, latest.device_category or 'N/A'
) if latest.device_category else 'N/A'
summary_lines.append(f"Last Application: {date_str}")
summary_lines.append(f"Last Device: {device} ({category})")
# Reason for last application
reason = latest.reason_for_application or 'Not specified'
summary_lines.append(f"Reason: {reason}")
# Authorizer
auth_name = f"{latest.authorizer_first_name or ''} {latest.authorizer_last_name or ''}".strip()
if auth_name:
summary_lines.append(f"Authorizer: {auth_name}")
# All devices received (unique)
devices = set()
for a in apps:
if a.base_device:
devices.add(a.base_device)
if len(devices) > 1:
summary_lines.append(f"All Devices: {', '.join(sorted(devices))}")
else:
summary_lines.append("No applications on file.")
profile.ai_summary = '\n'.join(summary_lines)
# --- RISK FLAGS ---
risk_lines = []
if app_count >= 2:
# Calculate frequency
dated_apps = [a for a in apps if a.application_date]
if len(dated_apps) >= 2:
dates = sorted([a.application_date for a in dated_apps])
total_span = (dates[-1] - dates[0]).days
if total_span > 0:
avg_days = total_span / (len(dates) - 1)
if avg_days < 365:
risk_lines.append(
f"High Frequency: {app_count} applications over "
f"{total_span} days (avg {avg_days:.0f} days apart)"
)
elif avg_days < 730:
risk_lines.append(
f"Moderate Frequency: {app_count} applications over "
f"{total_span // 365} year(s) (avg {avg_days:.0f} days apart)"
)
else:
risk_lines.append(
f"Normal Frequency: {app_count} applications over "
f"{total_span // 365} year(s) (avg {avg_days:.0f} days apart)"
)
# Check for multiple replacements
replacements = [a for a in apps if a.reason_for_application and 'replacement' in a.reason_for_application.lower()]
if len(replacements) >= 2:
risk_lines.append(f"Multiple Replacements: {len(replacements)} replacement applications")
if not risk_lines:
risk_lines.append("No flags identified.")
profile.ai_risk_flags = '\n'.join(risk_lines)
# ------------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------------
def action_view_claims(self):
"""Open sale orders for this client."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Claims - {self.display_name}',
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.partner_id.id), ('x_fc_sale_type', '!=', False)],
}
def action_view_applications(self):
"""Open parsed ADP application data for this client."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Applications - {self.display_name}',
'res_model': 'fusion.adp.application.data',
'view_mode': 'list,form',
'domain': [('profile_id', '=', self.id)],
}
def action_open_ai_chat(self):
"""Open AI chat about this client using Odoo's native AI agent."""
self.ensure_one()
agent = self.env.ref('fusion_claims.ai_agent_fusion_claims', raise_if_not_found=False)
if agent:
# Create channel with client context so the AI knows which client
channel = agent._create_ai_chat_channel()
# Post an initial context message about this client
initial_prompt = (
f"I want to ask about client {self.display_name} "
f"(Profile ID: {self.id}, Health Card: {self.health_card_number or 'N/A'}). "
f"Please look up their details."
)
return {
'type': 'ir.actions.client',
'tag': 'agent_chat_action',
'params': {
'channelId': channel.id,
'user_prompt': initial_prompt,
},
}
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
CASE_TYPE_SELECTION = [
('adp', 'ADP'),
('odsp', 'ODSP'),
('march_of_dimes', 'March of Dimes'),
('hardship', 'Hardship Funding'),
('acsd', 'ACSD'),
('muscular_dystrophy', 'Muscular Dystrophy'),
('insurance', 'Insurance'),
('wsib', 'WSIB'),
]
TYPE_DOMAINS = {
'adp': [('x_fc_sale_type', 'in', ['adp', 'adp_odsp'])],
'odsp': [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp'])],
'march_of_dimes': [('x_fc_sale_type', '=', 'march_of_dimes')],
'hardship': [('x_fc_sale_type', '=', 'hardship')],
'acsd': [('x_fc_client_type', '=', 'ACS')],
'muscular_dystrophy': [('x_fc_sale_type', '=', 'muscular_dystrophy')],
'insurance': [('x_fc_sale_type', '=', 'insurance')],
'wsib': [('x_fc_sale_type', '=', 'wsib')],
}
TYPE_LABELS = dict(CASE_TYPE_SELECTION)
class FusionClaimsDashboard(models.TransientModel):
_name = 'fusion.claims.dashboard'
_description = 'Fusion Claims Dashboard'
_rec_name = 'name'
name = fields.Char(default='Dashboard', readonly=True)
# Case counts by funding type
adp_count = fields.Integer(compute='_compute_stats')
odsp_count = fields.Integer(compute='_compute_stats')
march_of_dimes_count = fields.Integer(compute='_compute_stats')
hardship_count = fields.Integer(compute='_compute_stats')
acsd_count = fields.Integer(compute='_compute_stats')
muscular_dystrophy_count = fields.Integer(compute='_compute_stats')
insurance_count = fields.Integer(compute='_compute_stats')
wsib_count = fields.Integer(compute='_compute_stats')
total_profiles = fields.Integer(compute='_compute_stats')
# Panel selectors (4 panels)
panel1_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 1', default='adp')
panel2_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 2', default='odsp')
panel3_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 3', default='march_of_dimes')
panel4_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 4', default='hardship')
# Panel HTML
panel1_html = fields.Html(compute='_compute_panels', sanitize=False)
panel2_html = fields.Html(compute='_compute_panels', sanitize=False)
panel3_html = fields.Html(compute='_compute_panels', sanitize=False)
panel4_html = fields.Html(compute='_compute_panels', sanitize=False)
panel1_title = fields.Char(compute='_compute_panels')
panel2_title = fields.Char(compute='_compute_panels')
panel3_title = fields.Char(compute='_compute_panels')
panel4_title = fields.Char(compute='_compute_panels')
def _compute_stats(self):
SO = self.env['sale.order'].sudo()
Profile = self.env['fusion.client.profile'].sudo()
for rec in self:
rec.adp_count = SO.search_count(TYPE_DOMAINS['adp'])
rec.odsp_count = SO.search_count(TYPE_DOMAINS['odsp'])
rec.march_of_dimes_count = SO.search_count(TYPE_DOMAINS['march_of_dimes'])
rec.hardship_count = SO.search_count(TYPE_DOMAINS['hardship'])
rec.acsd_count = SO.search_count(TYPE_DOMAINS['acsd'])
rec.muscular_dystrophy_count = SO.search_count(TYPE_DOMAINS['muscular_dystrophy'])
rec.insurance_count = SO.search_count(TYPE_DOMAINS['insurance'])
rec.wsib_count = SO.search_count(TYPE_DOMAINS['wsib'])
rec.total_profiles = Profile.search_count([])
@api.depends('panel1_type', 'panel2_type', 'panel3_type', 'panel4_type')
def _compute_panels(self):
SO = self.env['sale.order'].sudo()
for rec in self:
for i in range(1, 5):
ptype = getattr(rec, f'panel{i}_type') or 'adp'
domain = TYPE_DOMAINS.get(ptype, [])
orders = SO.search(domain, order='create_date desc', limit=50)
count = SO.search_count(domain)
title = f'Window {i} - {TYPE_LABELS.get(ptype, ptype)} ({count} cases)'
html = rec._build_top_list(orders)
setattr(rec, f'panel{i}_title', title)
setattr(rec, f'panel{i}_html', html)
def _build_top_list(self, orders):
if not orders:
return '<p class="text-muted text-center py-4">No cases found</p>'
rows = []
for o in orders:
status = o.x_fc_adp_application_status or ''
status_label = dict(o._fields['x_fc_adp_application_status'].selection).get(status, status)
rows.append(
f'<tr>'
f'<td><a href="/odoo/sales/{o.id}">{o.name}</a></td>'
f'<td>{o.partner_id.name or ""}</td>'
f'<td>{status_label}</td>'
f'<td class="text-end">${o.amount_total:,.2f}</td>'
f'</tr>'
)
return (
'<table class="table table-sm table-hover mb-0">'
'<thead><tr><th>Order</th><th>Client</th><th>Status</th><th class="text-end">Total</th></tr></thead>'
'<tbody>' + ''.join(rows) + '</tbody></table>'
)
def action_open_order(self, order_id):
"""Open a specific sale order with breadcrumbs."""
return {
'type': 'ir.actions.act_window',
'name': 'Sale Order',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': order_id,
'target': 'current',
}
def action_open_adp(self):
return self._open_type_action('adp')
def action_open_odsp(self):
return self._open_type_action('odsp')
def action_open_march(self):
return self._open_type_action('march_of_dimes')
def action_open_hardship(self):
return self._open_type_action('hardship')
def action_open_acsd(self):
return self._open_type_action('acsd')
def action_open_muscular(self):
return self._open_type_action('muscular_dystrophy')
def action_open_insurance(self):
return self._open_type_action('insurance')
def action_open_wsib(self):
return self._open_type_action('wsib')
def action_open_profiles(self):
return {
'type': 'ir.actions.act_window', 'name': 'Client Profiles',
'res_model': 'fusion.client.profile', 'view_mode': 'list,form',
}
def _open_type_action(self, type_key):
return {
'type': 'ir.actions.act_window',
'name': f'{TYPE_LABELS.get(type_key, type_key)} Cases',
'res_model': 'sale.order', 'view_mode': 'list,form',
'domain': TYPE_DOMAINS.get(type_key, []),
}

View File

@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
# Fusion Claims - Professional Email Builder Mixin
# Provides consistent, dark/light mode safe email templates across all modules.
from odoo import models
class FusionEmailBuilderMixin(models.AbstractModel):
_name = 'fusion.email.builder.mixin'
_description = 'Fusion Email Builder Mixin'
# ------------------------------------------------------------------
# Color constants
# ------------------------------------------------------------------
_EMAIL_COLORS = {
'info': '#2B6CB0',
'success': '#38a169',
'attention': '#d69e2e',
'urgent': '#c53030',
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def _email_build(
self,
title,
summary,
sections=None,
note=None,
note_color=None,
email_type='info',
attachments_note=None,
button_url=None,
button_text='View Case Details',
sender_name=None,
extra_html='',
):
"""Build a complete professional email HTML string.
Args:
title: Email heading (e.g. "Application Approved")
summary: One-sentence summary HTML (may contain <strong> tags)
sections: list of (heading, rows) where rows is list of (label, value)
e.g. [('Case Details', [('Client', 'John'), ('Case', 'S30073')])]
note: Optional note/next-steps text (plain or HTML)
note_color: Override left-border color for note (default uses email_type)
email_type: 'info' | 'success' | 'attention' | 'urgent'
attachments_note: Optional string listing attached files
button_url: Optional CTA button URL
button_text: CTA button label
sender_name: Name for sign-off (defaults to current user)
extra_html: Any additional HTML to insert before sign-off
"""
accent = self._EMAIL_COLORS.get(email_type, self._EMAIL_COLORS['info'])
company = self._get_company_info()
parts = []
# -- Wrapper open + accent bar
parts.append(
f'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
f'max-width:600px;margin:0 auto;color:#2d3748;">'
f'<div style="height:4px;background-color:{accent};"></div>'
f'<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">'
)
# -- Company name
parts.append(
f'<p style="color:{accent};font-size:13px;font-weight:600;letter-spacing:0.5px;'
f'text-transform:uppercase;margin:0 0 24px 0;">{company["name"]}</p>'
)
# -- Title
parts.append(
f'<h2 style="color:#1a202c;font-size:22px;font-weight:700;'
f'margin:0 0 6px 0;line-height:1.3;">{title}</h2>'
)
# -- Summary
parts.append(
f'<p style="color:#718096;font-size:15px;line-height:1.5;'
f'margin:0 0 24px 0;">{summary}</p>'
)
# -- Sections (details tables)
if sections:
for heading, rows in sections:
parts.append(self._email_section(heading, rows))
# -- Note / Next Steps
if note:
nc = note_color or accent
parts.append(self._email_note(note, nc))
# -- Extra HTML
if extra_html:
parts.append(extra_html)
# -- Attachment note
if attachments_note:
parts.append(self._email_attachment_note(attachments_note))
# -- CTA Button
if button_url:
parts.append(self._email_button(button_url, button_text, accent))
# -- Sign-off
signer = sender_name or (self.env.user.name if self.env.user else '')
parts.append(
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
f'Best regards,<br/>'
f'<strong>{signer}</strong><br/>'
f'<span style="color:#718096;">{company["name"]}</span></p>'
)
# -- Close content card
parts.append('</div>')
# -- Footer
footer_parts = [company['name']]
if company['phone']:
footer_parts.append(company['phone'])
if company['email']:
footer_parts.append(company['email'])
footer_text = ' &middot; '.join(footer_parts)
parts.append(
f'<div style="padding:16px 28px;text-align:center;">'
f'<p style="color:#a0aec0;font-size:11px;line-height:1.5;margin:0;">'
f'{footer_text}<br/>'
f'This is an automated notification from the ADP Claims Management System.</p>'
f'</div>'
)
# -- Close wrapper
parts.append('</div>')
return ''.join(parts)
# ------------------------------------------------------------------
# Building blocks
# ------------------------------------------------------------------
def _email_section(self, heading, rows):
"""Build a labeled details table section.
Args:
heading: Section title (e.g. "Case Details")
rows: list of (label, value) tuples. Value can be plain text or HTML.
"""
if not rows:
return ''
html = (
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
f'color:#718096;text-transform:uppercase;letter-spacing:0.5px;'
f'border-bottom:2px solid #e2e8f0;">{heading}</td></tr>'
)
for label, value in rows:
if value is None or value == '' or value is False:
continue
html += (
f'<tr>'
f'<td style="padding:10px 14px;color:#718096;font-size:14px;'
f'border-bottom:1px solid #f0f0f0;width:35%;">{label}</td>'
f'<td style="padding:10px 14px;color:#2d3748;font-size:14px;'
f'border-bottom:1px solid #f0f0f0;">{value}</td>'
f'</tr>'
)
html += '</table>'
return html
def _email_note(self, text, color='#2B6CB0'):
"""Build a left-border accent note block."""
return (
f'<div style="border-left:3px solid {color};padding:12px 16px;'
f'margin:0 0 24px 0;background:#f7fafc;">'
f'<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">{text}</p>'
f'</div>'
)
def _email_button(self, url, text='View Case Details', color='#2B6CB0'):
"""Build a centered CTA button."""
return (
f'<p style="text-align:center;margin:28px 0;">'
f'<a href="{url}" style="display:inline-block;background:{color};color:#ffffff;'
f'padding:12px 28px;text-decoration:none;border-radius:6px;'
f'font-size:14px;font-weight:600;">{text}</a></p>'
)
def _email_attachment_note(self, description):
"""Build a dashed-border attachment callout.
Args:
description: e.g. "ADP Application (PDF), XML Data File"
"""
return (
f'<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:13px;color:#718096;">'
f'<strong style="color:#2d3748;">Attached:</strong> {description}</p>'
f'</div>'
)
def _email_status_badge(self, label, color='#2B6CB0'):
"""Return an inline status badge/pill HTML snippet."""
# Pick a light background tint for the badge
bg_map = {
'#38a169': '#f0fff4',
'#2B6CB0': '#ebf4ff',
'#d69e2e': '#fefcbf',
'#c53030': '#fff5f5',
}
bg = bg_map.get(color, '#ebf4ff')
return (
f'<span style="display:inline-block;background:{bg};color:{color};'
f'padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">'
f'{label}</span>'
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_company_info(self):
"""Return company name, phone, email for email templates."""
company = getattr(self, 'company_id', None) or self.env.company
return {
'name': company.name or 'Our Company',
'phone': company.phone or '',
'email': company.email or '',
}
def _email_is_enabled(self):
"""Check if email notifications are enabled in settings."""
ICP = self.env['ir.config_parameter'].sudo()
val = ICP.get_param('fusion_claims.enable_email_notifications', 'True')
return val.lower() in ('true', '1', 'yes')

View File

@@ -0,0 +1,390 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from odoo.tools.misc import file_path as get_resource_path
import json
import re
import os
import logging
_logger = logging.getLogger(__name__)
class FusionADPDeviceCode(models.Model):
_name = 'fusion.adp.device.code'
_description = 'ADP Device Code Reference (Mobility Manual)'
_order = 'device_type, device_code'
def _register_hook(self):
"""
Called when the model is loaded.
Re-loads device codes from packaged JSON on module upgrade.
"""
super()._register_hook()
# Use with_context to check if this is a module upgrade
# The data will be loaded via post_init_hook on install,
# and via this hook on upgrade (when module is reloaded)
try:
self.sudo()._load_packaged_device_codes()
except Exception as e:
_logger.warning("Could not auto-load device codes: %s", str(e))
# ==========================================================================
# MAIN FIELDS
# ==========================================================================
device_code = fields.Char(
string='Device Code',
required=True,
index=True,
help='ADP device code from the mobility manual',
)
name = fields.Char(
string='Description',
compute='_compute_name',
store=True,
)
device_type = fields.Char(
string='Device Type',
index=True,
help='Device type/category (e.g., Adult Wheeled Walker Type 1)',
)
manufacturer = fields.Char(
string='Manufacturer',
index=True,
help='Device manufacturer',
)
device_description = fields.Char(
string='Device Description',
help='Detailed device description from mobility manual',
)
max_quantity = fields.Integer(
string='Max Quantity',
default=1,
help='Maximum quantity that can be billed per claim',
)
adp_price = fields.Float(
string='ADP Price',
digits='Product Price',
help='Maximum price ADP will cover for this device',
)
sn_required = fields.Boolean(
string='Serial Number Required',
default=False,
help='Is serial number required for this device?',
)
# ==========================================================================
# TRACKING
# ==========================================================================
active = fields.Boolean(
string='Active',
default=True,
)
last_updated = fields.Datetime(
string='Last Updated',
default=fields.Datetime.now,
)
# ==========================================================================
# SQL CONSTRAINTS
# ==========================================================================
_sql_constraints = [
('device_code_uniq', 'unique(device_code)',
'Device code must be unique!'),
]
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================
@api.depends('device_code', 'adp_price', 'device_type', 'device_description')
def _compute_name(self):
for record in self:
if record.device_code:
if record.device_description:
record.name = f"{record.device_code} - {record.device_description} (${record.adp_price:.2f})"
else:
record.name = f"{record.device_code} (${record.adp_price:.2f})"
else:
record.name = ''
# ==========================================================================
# DEVICE TYPE LOOKUP (for wizard display)
# ==========================================================================
@api.model
def get_device_type_for_code(self, device_code):
"""Get the device type for a given device code."""
if not device_code:
return ''
device = self.search([('device_code', '=', device_code), ('active', '=', True)], limit=1)
return device.device_type or ''
@api.model
def get_unique_device_types(self):
"""Get list of unique device types from the database."""
self.flush_model()
self.env.cr.execute("""
SELECT DISTINCT device_type
FROM fusion_adp_device_code
WHERE device_type IS NOT NULL AND device_type != '' AND active = TRUE
ORDER BY device_type
""")
return [row[0] for row in self.env.cr.fetchall()]
# ==========================================================================
# LOOKUP METHODS
# ==========================================================================
@api.model
def get_device_info(self, device_code):
"""Get device info by code."""
if not device_code:
return None
device = self.search([('device_code', '=', device_code), ('active', '=', True)], limit=1)
if device:
return {
'device_code': device.device_code,
'max_quantity': device.max_quantity,
'adp_price': device.adp_price,
'sn_required': device.sn_required,
}
return None
@api.model
def validate_device_code(self, device_code):
"""Check if a device code exists in the mobility manual."""
if not device_code:
return False
return bool(self.search([('device_code', '=', device_code), ('active', '=', True)], limit=1))
# ==========================================================================
# TEXT CLEANING UTILITIES
# ==========================================================================
@staticmethod
def _clean_text(text):
"""Clean text from weird characters, normalize encoding."""
if not text:
return ''
# Convert to string if not already
text = str(text)
# Remove or replace problematic characters
# Replace curly quotes with straight quotes
text = text.replace('"', '"').replace('"', '"')
text = text.replace(''', "'").replace(''', "'")
# Remove non-printable characters except newlines
text = ''.join(char if char.isprintable() or char in '\n\r\t' else ' ' for char in text)
# Normalize multiple spaces
text = re.sub(r'\s+', ' ', text)
# Strip leading/trailing whitespace
return text.strip()
@staticmethod
def _parse_price(price_str):
"""Parse price string like '$64.00' or '$2,578.00' to float."""
if not price_str:
return 0.0
# Remove currency symbols, commas, spaces, quotes
price_str = str(price_str).strip()
price_str = re.sub(r'[\$,"\'\s]', '', price_str)
try:
return float(price_str)
except ValueError:
return 0.0
# ==========================================================================
# IMPORT FROM JSON
# ==========================================================================
@api.model
def import_from_json(self, json_data):
"""
Import device codes from JSON data.
Expected format (enhanced with device type, manufacturer, description):
[
{
"Device Type": "Adult Wheeled Walker Type 1",
"Manufacturer": "Drive Medical",
"Device Description": "One Button Or Dual Trigger Release",
"Device Code": "MW1D50005",
"Quantity": 1,
"ADP Price": 64.00,
"SN Required": "Yes"
},
...
]
"""
if isinstance(json_data, str):
try:
data = json.loads(json_data)
except json.JSONDecodeError as e:
raise UserError(_("Invalid JSON data: %s") % str(e))
else:
data = json_data
if not isinstance(data, list):
raise UserError(_("Expected a list of device codes"))
created = 0
updated = 0
errors = []
for idx, item in enumerate(data):
try:
device_code = self._clean_text(item.get('Device Code', '') or item.get('device_code', ''))
if not device_code:
errors.append(f"Row {idx + 1}: Missing device code")
continue
# Parse fields with cleaning
device_type = self._clean_text(item.get('Device Type', '') or item.get('device_type', ''))
manufacturer = self._clean_text(item.get('Manufacturer', '') or item.get('manufacturer', ''))
device_description = self._clean_text(item.get('Device Description', '') or item.get('device_description', ''))
# Parse quantity
qty_val = item.get('Quantity', 1) or item.get('Qty', 1) or item.get('quantity', 1)
max_qty = int(qty_val) if qty_val else 1
# Parse price (handles both raw number and string format)
price_val = item.get('ADP Price', 0) or item.get('Approved Price', 0) or item.get('adp_price', 0)
if isinstance(price_val, (int, float)):
adp_price = float(price_val)
else:
adp_price = self._parse_price(price_val)
# Parse serial requirement - handle boolean, string, and various formats
sn_raw = item.get('SN Required') or item.get('Serial') or item.get('SN') or item.get('sn_required') or 'No'
# Handle boolean values directly
if isinstance(sn_raw, bool):
sn_required = sn_raw
else:
sn_val = str(sn_raw).upper().strip()
sn_required = sn_val in ('YES', 'Y', 'TRUE', '1', 'T')
# Check if exists
existing = self.search([('device_code', '=', device_code)], limit=1)
vals = {
'device_type': device_type,
'manufacturer': manufacturer,
'device_description': device_description,
'max_quantity': max_qty,
'adp_price': adp_price,
'sn_required': sn_required,
'last_updated': fields.Datetime.now(),
'active': True,
}
if existing:
existing.write(vals)
updated += 1
else:
vals['device_code'] = device_code
self.create(vals)
created += 1
except Exception as e:
errors.append(f"Row {idx + 1}: {str(e)}")
return {
'created': created,
'updated': updated,
'errors': errors,
}
@api.model
def import_from_csv_file(self, file_path):
"""Import device codes from a CSV file (ADP Mobility Manual format).
Expected CSV columns: Device Type, Manufacturer, Device Description, Device Code, Qty, Approved Price, Serial
"""
import csv
try:
data = []
with open(file_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
# Skip empty rows
device_code = (row.get('Device Code', '') or '').strip()
if not device_code:
continue
data.append({
'Device Type': row.get('Device Type', ''),
'Manufacturer': row.get('Manufacturer', ''),
'Device Description': row.get('Device Description', ''),
'Device Code': device_code,
'Quantity': row.get('Qty', 1),
'ADP Price': row.get(' Approved Price ', '') or row.get('Approved Price', ''),
'SN Required': row.get('Serial', 'No'),
})
return self.import_from_json(data)
except FileNotFoundError:
raise UserError(_("File not found: %s") % file_path)
except Exception as e:
raise UserError(_("Error reading CSV file: %s") % str(e))
@api.model
def import_from_file(self, file_path):
"""Import device codes from a JSON file."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return self.import_from_json(data)
except FileNotFoundError:
raise UserError(_("File not found: %s") % file_path)
except json.JSONDecodeError as e:
raise UserError(_("Invalid JSON file: %s") % str(e))
except Exception as e:
raise UserError(_("Error reading file: %s") % str(e))
# ==========================================================================
# AUTO-LOAD FROM PACKAGED DATA FILE
# ==========================================================================
@api.model
def _load_packaged_device_codes(self):
"""
Load device codes from the packaged JSON file.
Called automatically on module install/upgrade via post_init_hook.
The JSON file is located at: fusion_claims/data/device_codes/adp_mobility_manual.json
"""
_logger.info("Loading ADP Mobility Manual device codes from packaged data file...")
# Get the path to the packaged JSON file
try:
json_path = get_resource_path('fusion_claims/data/device_codes/adp_mobility_manual.json')
except FileNotFoundError:
json_path = None
if not json_path or not os.path.exists(json_path):
_logger.warning("ADP Mobility Manual JSON file not found at expected location.")
return {'created': 0, 'updated': 0, 'errors': ['JSON file not found']}
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
result = self.import_from_json(data)
_logger.info(
"ADP Mobility Manual import complete: %d created, %d updated, %d errors",
result.get('created', 0),
result.get('updated', 0),
len(result.get('errors', []))
)
if result.get('errors'):
for error in result['errors'][:10]: # Log first 10 errors
_logger.warning("Import error: %s", error)
return result
except Exception as e:
_logger.error("Error loading ADP Mobility Manual: %s", str(e))
return {'created': 0, 'updated': 0, 'errors': [str(e)]}

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
import logging
from odoo import models, api, _
_logger = logging.getLogger(__name__)
class FusionCentralConfig(models.TransientModel):
_name = 'fusion_claims.config'
_description = 'Fusion Central Configuration Manager'
# =========================================================================
# ACTION METHODS
# =========================================================================
def action_detect_existing_fields(self):
"""Detect existing custom x_* fields and map them."""
ICP = self.env['ir.config_parameter'].sudo()
IrModelFields = self.env['ir.model.fields'].sudo()
detected = []
detected_details = []
# Search for all custom fields on relevant models
models_to_search = ['sale.order', 'sale.order.line', 'account.move', 'account.move.line', 'product.template']
# Find all custom x_* fields
all_custom_fields = IrModelFields.search([
('model', 'in', models_to_search),
('name', '=like', 'x_%'),
('state', '=', 'manual'),
])
_logger.debug("Found %d custom fields across models", len(all_custom_fields))
# Field patterns to detect (model, keywords, param_key, display_name)
# Keywords are checked if they appear anywhere in the field name
# NOTE: param_key must match the config_parameter in res_config_settings.py
field_mappings = [
# Sale Order header fields
('sale.order', ['sale_type', 'saletype', 'type_of_sale'], 'fusion_claims.field_sale_type', 'Sale Type'),
('sale.order', ['client_type', 'clienttype', 'customer_type'], 'fusion_claims.field_so_client_type', 'SO Client Type'),
('sale.order', ['authorizer', 'authorized', 'approver'], 'fusion_claims.field_so_authorizer', 'SO Authorizer'),
('sale.order', ['claim_number', 'claimnumber', 'claim_no', 'claim_num'], 'fusion_claims.field_so_claim_number', 'SO Claim Number'),
('sale.order', ['client_ref_1', 'clientref1', 'reference_1'], 'fusion_claims.field_so_client_ref_1', 'SO Client Ref 1'),
('sale.order', ['client_ref_2', 'clientref2', 'reference_2'], 'fusion_claims.field_so_client_ref_2', 'SO Client Ref 2'),
('sale.order', ['delivery_date', 'deliverydate', 'adp_delivery'], 'fusion_claims.field_so_delivery_date', 'SO Delivery Date'),
('sale.order', ['service_start', 'servicestart'], 'fusion_claims.field_so_service_start', 'SO Service Start'),
('sale.order', ['service_end', 'serviceend'], 'fusion_claims.field_so_service_end', 'SO Service End'),
('sale.order', ['adp_status', 'adpstatus'], 'fusion_claims.field_so_adp_status', 'SO ADP Status'),
# Sale Order line fields
('sale.order.line', ['serial', 'sn', 's_n'], 'fusion_claims.field_sol_serial', 'SO Line Serial'),
('sale.order.line', ['placement', 'device_placement'], 'fusion_claims.field_sol_placement', 'SO Line Placement'),
# Invoice header fields
('account.move', ['invoice_type', 'invoicetype', 'inv_type', 'type_of_invoice'], 'fusion_claims.field_invoice_type', 'Invoice Type'),
('account.move', ['client_type', 'clienttype', 'customer_type'], 'fusion_claims.field_inv_client_type', 'Invoice Client Type'),
('account.move', ['authorizer', 'authorized', 'approver'], 'fusion_claims.field_inv_authorizer', 'Invoice Authorizer'),
('account.move', ['claim_number', 'claimnumber', 'claim_no'], 'fusion_claims.field_inv_claim_number', 'Invoice Claim Number'),
('account.move', ['client_ref_1', 'clientref1', 'reference_1'], 'fusion_claims.field_inv_client_ref_1', 'Invoice Client Ref 1'),
('account.move', ['client_ref_2', 'clientref2', 'reference_2'], 'fusion_claims.field_inv_client_ref_2', 'Invoice Client Ref 2'),
('account.move', ['delivery_date', 'deliverydate', 'adp_delivery'], 'fusion_claims.field_inv_delivery_date', 'Invoice Delivery Date'),
('account.move', ['service_start', 'servicestart'], 'fusion_claims.field_inv_service_start', 'Invoice Service Start'),
('account.move', ['service_end', 'serviceend'], 'fusion_claims.field_inv_service_end', 'Invoice Service End'),
# Invoice line fields
('account.move.line', ['serial', 'sn', 's_n'], 'fusion_claims.field_aml_serial', 'Invoice Line Serial'),
('account.move.line', ['placement', 'device_placement'], 'fusion_claims.field_aml_placement', 'Invoice Line Placement'),
# Product fields
('product.template', ['adp_device', 'adp_code', 'adp_sku', 'device_code', 'sku'], 'fusion_claims.field_product_code', 'Product ADP Code'),
]
for model, keywords, param_key, display_name in field_mappings:
# Find fields on this model that contain any of the keywords
model_fields = all_custom_fields.filtered(lambda f: f.model == model)
model_fields_sorted = sorted(model_fields, key=lambda f: f.name)
matched_field = None
for field in model_fields_sorted:
field_name_lower = field.name.lower()
for keyword in keywords:
if keyword in field_name_lower:
# Skip our own x_fc_* fields - we want to find other custom fields
if field.name.startswith('x_fc_'):
continue
matched_field = field
break
if matched_field:
break
if matched_field:
ICP.set_param(param_key, matched_field.name)
detected.append(matched_field.name)
detected_details.append(f"{display_name}: {matched_field.name} ({model})")
_logger.debug("Mapped %s -> %s on %s", param_key, matched_field.name, model)
# Also list any unmapped custom fields for reference
unmapped = []
for field in all_custom_fields:
if field.name not in detected:
unmapped.append(f"{field.model}.{field.name}")
if detected_details:
message = _("Detected and mapped %d fields:\n%s") % (len(detected), "\n".join(detected_details))
if unmapped:
message += _("\n\nOther custom fields found (not mapped):\n") + "\n".join(unmapped[:10])
if len(unmapped) > 10:
message += f"\n... and {len(unmapped) - 10} more"
message += _("\n\n⚠️ IMPORTANT: Save settings and reload page to see changes.")
else:
message = _("No matching fields found.\n\nCustom fields found:\n") + "\n".join(unmapped[:15]) if unmapped else _("No custom fields found on relevant models.")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Field Detection Complete"),
'message': message,
'type': 'success' if detected else 'warning',
'sticky': True,
}
}
# (Migration and field protection methods removed)

View File

@@ -0,0 +1,799 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
from markupsafe import Markup
from datetime import timedelta
import logging
_logger = logging.getLogger(__name__)
class FusionLoanerCheckout(models.Model):
"""Track loaner equipment checkouts and returns."""
_name = 'fusion.loaner.checkout'
_description = 'Loaner Equipment Checkout'
_order = 'checkout_date desc, id desc'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin']
# =========================================================================
# REFERENCE FIELDS
# =========================================================================
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='set null',
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
required=True,
tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer',
help='Therapist/Authorizer associated with this loaner',
)
sales_rep_id = fields.Many2one(
'res.users',
string='Sales Rep',
default=lambda self: self.env.user,
tracking=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
# =========================================================================
# PRODUCT & SERIAL
# =========================================================================
product_id = fields.Many2one(
'product.product',
string='Product',
required=True,
domain="[('x_fc_can_be_loaned', '=', True)]",
tracking=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
domain="[('product_id', '=', product_id)]",
tracking=True,
)
product_description = fields.Text(
string='Product Description',
related='product_id.description_sale',
)
# =========================================================================
# DATES
# =========================================================================
checkout_date = fields.Date(
string='Checkout Date',
required=True,
default=fields.Date.context_today,
tracking=True,
)
loaner_period_days = fields.Integer(
string='Loaner Period (Days)',
default=7,
help='Number of free loaner days before rental conversion',
)
expected_return_date = fields.Date(
string='Expected Return Date',
compute='_compute_expected_return_date',
store=True,
)
actual_return_date = fields.Date(
string='Actual Return Date',
tracking=True,
)
days_out = fields.Integer(
string='Days Out',
compute='_compute_days_out',
)
days_overdue = fields.Integer(
string='Days Overdue',
compute='_compute_days_overdue',
)
# =========================================================================
# STATUS
# =========================================================================
state = fields.Selection([
('draft', 'Draft'),
('checked_out', 'Checked Out'),
('overdue', 'Overdue'),
('rental_pending', 'Rental Conversion Pending'),
('returned', 'Returned'),
('converted_rental', 'Converted to Rental'),
('lost', 'Lost/Write-off'),
], string='Status', default='draft', tracking=True, required=True)
# =========================================================================
# LOCATION
# =========================================================================
delivery_address = fields.Text(
string='Delivery Address',
help='Where the loaner was delivered',
)
return_location_id = fields.Many2one(
'stock.location',
string='Return Location',
domain="[('usage', '=', 'internal')]",
help='Where the loaner was returned to (store, warehouse, etc.)',
tracking=True,
)
checked_out_by_id = fields.Many2one(
'res.users',
string='Checked Out By',
default=lambda self: self.env.user,
)
returned_to_id = fields.Many2one(
'res.users',
string='Returned To',
)
# =========================================================================
# CHECKOUT CONDITION
# =========================================================================
checkout_condition = fields.Selection([
('excellent', 'Excellent'),
('good', 'Good'),
('fair', 'Fair'),
('needs_repair', 'Needs Repair'),
], string='Checkout Condition', default='excellent')
checkout_notes = fields.Text(
string='Checkout Notes',
)
checkout_photo_ids = fields.Many2many(
'ir.attachment',
'fusion_loaner_checkout_photo_rel',
'checkout_id',
'attachment_id',
string='Checkout Photos',
)
# =========================================================================
# RETURN CONDITION
# =========================================================================
return_condition = fields.Selection([
('excellent', 'Excellent'),
('good', 'Good'),
('fair', 'Fair'),
('needs_repair', 'Needs Repair'),
('damaged', 'Damaged'),
], string='Return Condition')
return_notes = fields.Text(
string='Return Notes',
)
return_photo_ids = fields.Many2many(
'ir.attachment',
'fusion_loaner_return_photo_rel',
'checkout_id',
'attachment_id',
string='Return Photos',
)
# =========================================================================
# REMINDER TRACKING
# =========================================================================
reminder_day5_sent = fields.Boolean(
string='Day 5 Reminder Sent',
default=False,
)
reminder_day8_sent = fields.Boolean(
string='Day 8 Warning Sent',
default=False,
)
reminder_day10_sent = fields.Boolean(
string='Day 10 Final Notice Sent',
default=False,
)
# =========================================================================
# RENTAL CONVERSION
# =========================================================================
rental_order_id = fields.Many2one(
'sale.order',
string='Rental Order',
help='Sale order created when loaner converted to rental',
)
rental_conversion_date = fields.Date(
string='Rental Conversion Date',
)
# =========================================================================
# STOCK MOVES
# =========================================================================
checkout_move_id = fields.Many2one(
'stock.move',
string='Checkout Stock Move',
)
return_move_id = fields.Many2one(
'stock.move',
string='Return Stock Move',
)
# =========================================================================
# HISTORY
# =========================================================================
history_ids = fields.One2many(
'fusion.loaner.history',
'checkout_id',
string='History',
)
history_count = fields.Integer(
compute='_compute_history_count',
string='History Count',
)
# =========================================================================
# COMPUTED FIELDS
# =========================================================================
@api.depends('checkout_date', 'loaner_period_days')
def _compute_expected_return_date(self):
for record in self:
if record.checkout_date and record.loaner_period_days:
record.expected_return_date = record.checkout_date + timedelta(days=record.loaner_period_days)
else:
record.expected_return_date = False
@api.depends('checkout_date', 'actual_return_date')
def _compute_days_out(self):
today = fields.Date.today()
for record in self:
if record.checkout_date:
end_date = record.actual_return_date or today
record.days_out = (end_date - record.checkout_date).days
else:
record.days_out = 0
@api.depends('expected_return_date', 'actual_return_date', 'state')
def _compute_days_overdue(self):
today = fields.Date.today()
for record in self:
if record.state in ('returned', 'converted_rental', 'lost'):
record.days_overdue = 0
elif record.expected_return_date:
end_date = record.actual_return_date or today
overdue = (end_date - record.expected_return_date).days
record.days_overdue = max(0, overdue)
else:
record.days_overdue = 0
def _compute_history_count(self):
for record in self:
record.history_count = len(record.history_ids)
# =========================================================================
# ONCHANGE
# =========================================================================
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.loaner_period_days = self.product_id.x_fc_loaner_period_days or 7
self.lot_id = False
@api.onchange('sale_order_id')
def _onchange_sale_order_id(self):
if self.sale_order_id:
self.partner_id = self.sale_order_id.partner_id
self.authorizer_id = self.sale_order_id.x_fc_authorizer_id
self.sales_rep_id = self.sale_order_id.user_id
self.delivery_address = self.sale_order_id.partner_shipping_id.contact_address if self.sale_order_id.partner_shipping_id else ''
# =========================================================================
# CRUD
# =========================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.loaner.checkout') or _('New')
records = super().create(vals_list)
for record in records:
record._log_history('create', 'Loaner checkout created')
return records
# =========================================================================
# ACTIONS
# =========================================================================
def action_checkout(self):
"""Confirm the loaner checkout."""
self.ensure_one()
if self.state != 'draft':
raise UserError(_("Can only checkout from draft state."))
if not self.product_id:
raise UserError(_("Please select a product."))
self.write({'state': 'checked_out'})
self._log_history('checkout', f'Loaner checked out to {self.partner_id.name}')
# Stock move is non-blocking -- use savepoint so failure doesn't roll back checkout
try:
with self.env.cr.savepoint():
self._create_checkout_stock_move()
except Exception as e:
_logger.warning(f"Stock move failed for checkout {self.name} (non-blocking): {e}")
self._send_checkout_email()
# Post to chatter
self.message_post(
body=Markup(
'<div class="alert alert-success">'
f'<strong>Loaner Checked Out</strong><br/>'
f'Product: {self.product_id.name}<br/>'
f'Serial: {self.lot_id.name if self.lot_id else "N/A"}<br/>'
f'Expected Return: {self.expected_return_date}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_return(self):
"""Open return wizard."""
self.ensure_one()
return {
'name': _('Return Loaner'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.return.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_checkout_id': self.id,
},
}
def action_process_return(self, return_condition, return_notes=None, return_photos=None, return_location_id=None):
"""Process the loaner return."""
self.ensure_one()
if self.state not in ('checked_out', 'overdue', 'rental_pending'):
raise UserError(_("Cannot return a loaner that is not checked out."))
vals = {
'state': 'returned',
'actual_return_date': fields.Date.today(),
'return_condition': return_condition,
'return_notes': return_notes,
'returned_to_id': self.env.user.id,
}
if return_location_id:
vals['return_location_id'] = return_location_id
if return_photos:
vals['return_photo_ids'] = [(6, 0, return_photos)]
self.write(vals)
self._log_history('return', f'Loaner returned in {return_condition} condition')
try:
with self.env.cr.savepoint():
self._create_return_stock_move()
except Exception as e:
_logger.warning(f"Stock move failed for return {self.name} (non-blocking): {e}")
self._send_return_email()
# Post to chatter
self.message_post(
body=Markup(
'<div class="alert alert-info">'
f'<strong>Loaner Returned</strong><br/>'
f'Condition: {return_condition}<br/>'
f'Days Out: {self.days_out}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_mark_lost(self):
"""Mark loaner as lost."""
self.ensure_one()
self.write({'state': 'lost'})
self._log_history('lost', 'Loaner marked as lost/write-off')
self.message_post(
body=Markup(
'<div class="alert alert-danger">'
'<strong>Loaner Marked as Lost</strong><br/>'
f'Product: {self.product_id.name}<br/>'
f'Serial: {self.lot_id.name if self.lot_id else "N/A"}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_convert_to_rental(self):
"""Flag for rental conversion."""
self.ensure_one()
self.write({
'state': 'rental_pending',
'rental_conversion_date': fields.Date.today(),
})
self._log_history('rental_pending', 'Loaner flagged for rental conversion')
self._send_rental_conversion_email()
def action_view_history(self):
"""View loaner history."""
self.ensure_one()
return {
'name': _('Loaner History'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.history',
'view_mode': 'tree,form',
'domain': [('checkout_id', '=', self.id)],
'context': {'default_checkout_id': self.id},
}
# =========================================================================
# STOCK MOVES
# =========================================================================
def _get_loaner_location(self):
"""Get the loaner stock location."""
location = self.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
if not location:
# Fallback to main stock
location = self.env.ref('stock.stock_location_stock')
return location
def _get_customer_location(self):
"""Get customer location for stock moves."""
return self.env.ref('stock.stock_location_customers')
def _create_checkout_stock_move(self):
"""Create stock move for checkout. Non-blocking -- checkout proceeds even if move fails."""
if not self.lot_id:
return # No serial tracking
try:
source_location = self._get_loaner_location()
dest_location = self._get_customer_location()
move_vals = {
'name': f'Loaner Checkout: {self.name}',
'product_id': self.product_id.id,
'product_uom_qty': 1,
'product_uom': self.product_id.uom_id.id,
'location_id': source_location.id,
'location_dest_id': dest_location.id,
'origin': self.name,
'company_id': self.company_id.id,
'procure_method': 'make_to_stock',
}
move = self.env['stock.move'].sudo().create(move_vals)
move._action_confirm()
move._action_assign()
# Set the lot on move line
if move.move_line_ids:
move.move_line_ids.write({'lot_id': self.lot_id.id})
move._action_done()
self.checkout_move_id = move.id
except Exception as e:
_logger.warning(f"Could not create checkout stock move (non-blocking): {e}")
def _create_return_stock_move(self):
"""Create stock move for return. Uses return_location_id if set, otherwise Loaner Stock."""
if not self.lot_id:
return
try:
source_location = self._get_customer_location()
dest_location = self.return_location_id or self._get_loaner_location()
move_vals = {
'name': f'Loaner Return: {self.name}',
'product_id': self.product_id.id,
'product_uom_qty': 1,
'product_uom': self.product_id.uom_id.id,
'location_id': source_location.id,
'location_dest_id': dest_location.id,
'origin': self.name,
'company_id': self.company_id.id,
'procure_method': 'make_to_stock',
}
move = self.env['stock.move'].sudo().create(move_vals)
move._action_confirm()
move._action_assign()
if move.move_line_ids:
move.move_line_ids.write({'lot_id': self.lot_id.id})
move._action_done()
self.return_move_id = move.id
except Exception as e:
_logger.warning(f"Could not create return stock move: {e}")
# =========================================================================
# HISTORY LOGGING
# =========================================================================
def _log_history(self, action, notes=None):
"""Log action to history."""
self.ensure_one()
self.env['fusion.loaner.history'].create({
'checkout_id': self.id,
'lot_id': self.lot_id.id if self.lot_id else False,
'action': action,
'notes': notes,
})
# =========================================================================
# EMAIL METHODS
# =========================================================================
def _get_email_recipients(self):
"""Get all email recipients for loaner notifications."""
recipients = {
'client_email': self.partner_id.email if self.partner_id else None,
'authorizer_email': self.authorizer_id.email if self.authorizer_id else None,
'sales_rep_email': self.sales_rep_id.email if self.sales_rep_id else None,
'office_emails': [],
}
# Get office emails from company
company = self.company_id or self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
recipients['office_emails'] = [p.email for p in office_partners if p.email]
return recipients
def _send_checkout_email(self):
"""Send checkout confirmation email to all parties."""
self.ensure_one()
recipients = self._get_email_recipients()
to_emails = [e for e in [recipients['client_email'], recipients['authorizer_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
if not to_emails:
return False
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
expected_return = self.expected_return_date.strftime('%B %d, %Y') if self.expected_return_date else 'N/A'
body_html = self._email_build(
title='Loaner Equipment Checkout',
summary=f'Loaner equipment has been checked out for <strong>{client_name}</strong>.',
email_type='info',
sections=[('Loaner Details', [
('Reference', self.name),
('Product', product_name),
('Serial Number', self.lot_id.name if self.lot_id else None),
('Checkout Date', self.checkout_date.strftime('%B %d, %Y') if self.checkout_date else None),
('Expected Return', expected_return),
('Loaner Period', f'{self.loaner_period_days} days'),
])],
note='<strong>Important:</strong> Please return the loaner equipment by the expected return date. '
'If not returned on time, rental charges may apply.',
note_color='#d69e2e',
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Loaner Checkout - {product_name} - {self.name}',
'body_html': body_html,
'email_to': ', '.join(to_emails),
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send checkout email for {self.name}: {e}")
return False
def _send_return_email(self):
"""Send return confirmation email."""
self.ensure_one()
recipients = self._get_email_recipients()
to_emails = [e for e in [recipients['client_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e]
if not to_emails:
return False
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
body_html = self._email_build(
title='Loaner Equipment Returned',
summary=f'Thank you for returning the loaner equipment, <strong>{client_name}</strong>.',
email_type='success',
sections=[('Return Details', [
('Reference', self.name),
('Product', product_name),
('Return Date', self.actual_return_date.strftime('%B %d, %Y') if self.actual_return_date else None),
('Condition', self.return_condition or None),
('Days Out', str(self.days_out)),
])],
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Loaner Returned - {product_name} - {self.name}',
'body_html': body_html,
'email_to': ', '.join(to_emails),
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send return email for {self.name}: {e}")
return False
def _send_rental_conversion_email(self):
"""Send rental conversion notification."""
self.ensure_one()
recipients = self._get_email_recipients()
to_emails = [e for e in [recipients['client_email'], recipients['authorizer_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
if not to_emails and not cc_emails:
return False
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
weekly_rate = self.product_id.x_fc_rental_price_weekly or 0
monthly_rate = self.product_id.x_fc_rental_price_monthly or 0
body_html = self._email_build(
title='Loaner Rental Conversion Notice',
summary=f'The loaner equipment for <strong>{client_name}</strong> has exceeded the free loaner period.',
email_type='urgent',
sections=[('Equipment Details', [
('Reference', self.name),
('Product', product_name),
('Days Out', str(self.days_out)),
('Days Overdue', str(self.days_overdue)),
('Weekly Rental Rate', f'${weekly_rate:.2f}'),
('Monthly Rental Rate', f'${monthly_rate:.2f}'),
])],
note='<strong>Action required:</strong> Please return the equipment or contact us to arrange '
'a rental agreement. Rental charges will apply until the equipment is returned.',
note_color='#c53030',
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'Loaner Rental Conversion - {product_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send rental conversion email for {self.name}: {e}")
return False
def _send_reminder_email(self, reminder_type):
"""Send reminder email based on type (day5, day8, day10)."""
self.ensure_one()
recipients = self._get_email_recipients()
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
expected_return = self.expected_return_date.strftime('%B %d, %Y') if self.expected_return_date else 'N/A'
if reminder_type == 'day5':
to_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
cc_emails = []
subject = f'Loaner Reminder: {product_name} - Day 5'
email_type = 'attention'
message = (f'The loaner equipment for {client_name} has been out for 5 days. '
f'Please follow up to arrange return.')
elif reminder_type == 'day8':
to_emails = [e for e in [recipients['client_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
subject = f'Loaner Return Reminder - {product_name}'
email_type = 'attention'
message = (f'Your loaner equipment has been out for 8 days. '
f'Please return it soon or it may be converted to a rental.')
else:
to_emails = [e for e in [recipients['client_email'], recipients['authorizer_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
subject = f'Loaner Return Required - {product_name}'
email_type = 'urgent'
message = (f'Your loaner equipment has been out for {self.days_out} days. '
f'If not returned, rental charges will apply.')
if not to_emails:
return False
body_html = self._email_build(
title='Loaner Equipment Reminder',
summary=message,
email_type=email_type,
sections=[('Loaner Details', [
('Reference', self.name),
('Client', client_name),
('Product', product_name),
('Days Out', str(self.days_out)),
('Expected Return', expected_return),
])],
)
try:
self.env['mail.mail'].sudo().create({
'subject': subject,
'body_html': body_html,
'email_to': ', '.join(to_emails),
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send {reminder_type} reminder for {self.name}: {e}")
return False
# =========================================================================
# CRON METHODS
# =========================================================================
@api.model
def _cron_check_overdue_loaners(self):
"""Daily cron to check for overdue loaners and send reminders."""
today = fields.Date.today()
# Find all active loaners
active_loaners = self.search([
('state', 'in', ['checked_out', 'overdue', 'rental_pending']),
])
for loaner in active_loaners:
days_out = loaner.days_out
# Update overdue status
if loaner.state == 'checked_out' and loaner.expected_return_date and today > loaner.expected_return_date:
loaner.write({'state': 'overdue'})
loaner._log_history('overdue', f'Loaner is now overdue by {loaner.days_overdue} days')
# Day 5 reminder
if days_out >= 5 and not loaner.reminder_day5_sent:
loaner._send_reminder_email('day5')
loaner.reminder_day5_sent = True
loaner._log_history('reminder_sent', 'Day 5 reminder sent')
# Day 8 warning
if days_out >= 8 and not loaner.reminder_day8_sent:
loaner._send_reminder_email('day8')
loaner.reminder_day8_sent = True
loaner._log_history('reminder_sent', 'Day 8 rental warning sent')
# Day 10 final notice
if days_out >= 10 and not loaner.reminder_day10_sent:
loaner._send_reminder_email('day10')
loaner.reminder_day10_sent = True
loaner._log_history('reminder_sent', 'Day 10 final notice sent')
# Flag for rental conversion
if loaner.state != 'rental_pending':
loaner.action_convert_to_rental()

View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class FusionLoanerHistory(models.Model):
"""Audit trail for loaner equipment actions."""
_name = 'fusion.loaner.history'
_description = 'Loaner History Log'
_order = 'action_date desc, id desc'
# =========================================================================
# REFERENCE FIELDS
# =========================================================================
checkout_id = fields.Many2one(
'fusion.loaner.checkout',
string='Checkout Record',
ondelete='cascade',
required=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
help='The serial number this action relates to',
)
product_id = fields.Many2one(
'product.product',
string='Product',
related='checkout_id.product_id',
store=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
related='checkout_id.partner_id',
store=True,
)
# =========================================================================
# ACTION DETAILS
# =========================================================================
action = fields.Selection([
('create', 'Created'),
('checkout', 'Checked Out'),
('return', 'Returned'),
('condition_update', 'Condition Updated'),
('reminder_sent', 'Reminder Sent'),
('overdue', 'Marked Overdue'),
('rental_pending', 'Rental Conversion Pending'),
('rental_converted', 'Converted to Rental'),
('lost', 'Marked as Lost'),
('note', 'Note Added'),
], string='Action', required=True)
action_date = fields.Datetime(
string='Date/Time',
default=fields.Datetime.now,
required=True,
)
user_id = fields.Many2one(
'res.users',
string='User',
default=lambda self: self.env.user,
required=True,
)
notes = fields.Text(
string='Notes',
)
# =========================================================================
# DISPLAY
# =========================================================================
def _get_action_label(self):
"""Get human-readable action label."""
action_labels = dict(self._fields['action'].selection)
return action_labels.get(self.action, self.action)
def name_get(self):
result = []
for record in self:
name = f"{record.checkout_id.name} - {record._get_action_label()}"
result.append((record.id, name))
return result
# =========================================================================
# SEARCH BY SERIAL
# =========================================================================
@api.model
def get_history_by_serial(self, lot_id):
"""Get all history for a specific serial number."""
return self.search([('lot_id', '=', lot_id)], order='action_date desc')
@api.model
def get_history_by_product(self, product_id):
"""Get all history for a specific product."""
return self.search([('product_id', '=', product_id)], order='action_date desc')

View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
import base64
import logging
from io import BytesIO
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionSaSignatureTemplate(models.Model):
_name = 'fusion.sa.signature.template'
_description = 'SA Mobility Signature Position Template'
_order = 'name'
name = fields.Char(string='Template Name', required=True)
active = fields.Boolean(default=True)
notes = fields.Text(string='Notes')
sa_default_sig_page = fields.Integer(string='Default Signature Page', default=2)
# Absolute PDF point coordinates. Y = distance from top of page.
sa_sig_name_x = fields.Integer(string='Name X', default=105)
sa_sig_name_y = fields.Integer(string='Name Y from top', default=97)
sa_sig_date_x = fields.Integer(string='Date X', default=430)
sa_sig_date_y = fields.Integer(string='Date Y from top', default=97)
sa_sig_x = fields.Integer(string='Signature X', default=72)
sa_sig_y = fields.Integer(string='Signature Y from top', default=68)
sa_sig_w = fields.Integer(string='Signature Width', default=190)
sa_sig_h = fields.Integer(string='Signature Height', default=25)
preview_pdf = fields.Binary(
string='Sample PDF',
help='Upload a sample SA Mobility approval form to preview signature placement.',
attachment=True,
)
preview_pdf_filename = fields.Char(string='PDF Filename')
preview_pdf_page = fields.Integer(
string='Preview Page', default=0,
help='Page to render preview for. 0 = use Default Signature Page.',
)
preview_image = fields.Binary(
string='Preview', readonly=True,
compute='_compute_preview_image',
)
@api.depends(
'preview_pdf', 'preview_pdf_page', 'sa_default_sig_page',
'sa_sig_name_x', 'sa_sig_name_y',
'sa_sig_date_x', 'sa_sig_date_y',
'sa_sig_x', 'sa_sig_y', 'sa_sig_w', 'sa_sig_h',
)
def _compute_preview_image(self):
for rec in self:
if not rec.preview_pdf:
rec.preview_image = False
continue
try:
rec.preview_image = rec._render_preview()
except Exception as e:
_logger.warning("SA template preview render failed: %s", e)
rec.preview_image = False
def _render_preview(self):
"""Render the sample PDF page with colored markers for all signature positions."""
self.ensure_one()
from odoo.tools.pdf import PdfFileReader
pdf_bytes = base64.b64decode(self.preview_pdf)
reader = PdfFileReader(BytesIO(pdf_bytes))
num_pages = reader.getNumPages()
page_num = self.preview_pdf_page or self.sa_default_sig_page or 2
page_idx = page_num - 1
if page_idx < 0 or page_idx >= num_pages:
return False
try:
from pdf2image import convert_from_bytes
except ImportError:
_logger.warning("pdf2image not installed")
return False
images = convert_from_bytes(
pdf_bytes, first_page=page_idx + 1, last_page=page_idx + 1, dpi=150,
)
if not images:
return False
from PIL import ImageDraw, ImageFont
img = images[0]
draw = ImageDraw.Draw(img)
page = reader.getPage(page_idx)
page_w_pts = float(page.mediaBox.getWidth())
page_h_pts = float(page.mediaBox.getHeight())
img_w, img_h = img.size
sx = img_w / page_w_pts
sy = img_h / page_h_pts
try:
font_b = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
font_sm = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
except Exception:
font_b = font = font_sm = ImageFont.load_default()
def _draw_sample_text(label, x_pts, y_top_pts, color, sample_text):
px_x = int(x_pts * sx)
px_y = int(y_top_pts * sy)
draw.text((px_x, px_y - 16), sample_text, fill=color, font=font_b)
draw.text((px_x, px_y + 2), label, fill=color, font=font_sm)
def _draw_box(label, x_pts, y_top_pts, w_pts, h_pts, color):
px_x = int(x_pts * sx)
px_y = int(y_top_pts * sy)
pw = int(w_pts * sx)
ph = int(h_pts * sy)
for off in range(3):
draw.rectangle(
[px_x - off, px_y - off, px_x + pw + off, px_y + ph + off],
outline=color,
)
draw.text((px_x + 4, px_y + 4), label, fill=color, font=font_sm)
_draw_sample_text(
"Name", self.sa_sig_name_x, self.sa_sig_name_y,
'blue', "John Smith",
)
_draw_sample_text(
"Date", self.sa_sig_date_x, self.sa_sig_date_y,
'purple', "2026-02-17",
)
_draw_box(
"Signature", self.sa_sig_x, self.sa_sig_y,
self.sa_sig_w, self.sa_sig_h, 'red',
)
buf = BytesIO()
img.save(buf, format='PNG')
return base64.b64encode(buf.getvalue())
def get_sa_coordinates(self, page_h=792):
"""Convert to ReportLab bottom-origin coordinates.
Template stores Y as distance from TOP of page.
ReportLab uses Y from BOTTOM.
For text (name/date): baseline Y = page_h - y_from_top
For signature image: drawImage Y is bottom-left corner,
so Y = page_h - y_from_top - height
"""
self.ensure_one()
return {
'name_x': self.sa_sig_name_x,
'name_y': page_h - self.sa_sig_name_y,
'date_x': self.sa_sig_date_x,
'date_y': page_h - self.sa_sig_date_y,
'sig_x': self.sa_sig_x,
'sig_y': page_h - self.sa_sig_y - self.sa_sig_h,
'sig_w': self.sa_sig_w,
'sig_h': self.sa_sig_h,
}

View File

@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import models
class ProductProduct(models.Model):
_inherit = 'product.product'
def get_adp_device_code(self):
"""
Get ADP device code from the field mapped in fusion settings.
The field name is configured in Settings → Sales → Fusion Central →
Field Mappings → Product ADP Code Field.
Checks the mapped field on the product variant first, then on template.
Returns the value from the mapped field, or empty string if not found.
"""
self.ensure_one()
# Get the mapped field name from fusion settings
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_product_code', 'x_fc_adp_device_code')
if not field_name:
return ''
# Check if the mapped field exists on the product variant (product.product)
if field_name in self._fields:
value = getattr(self, field_name, '') or ''
if value:
return value
# Check if the mapped field exists on the product template
if self.product_tmpl_id and field_name in self.product_tmpl_id._fields:
value = getattr(self.product_tmpl_id, field_name, '') or ''
if value:
return value
return ''
def get_adp_price(self):
"""
Get ADP price from the field mapped in fusion settings.
The field name is configured in Settings → Sales → Fusion Central →
Field Mappings → Product ADP Price Field.
Checks the mapped field on the product variant first, then on template.
Returns the value from the mapped field, or 0.0 if not found.
"""
self.ensure_one()
# Get the mapped field name from fusion settings
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_product_adp_price', 'x_fc_adp_price')
if not field_name:
return 0.0
# Check if the mapped field exists on the product variant (product.product)
if field_name in self._fields:
value = getattr(self, field_name, 0.0) or 0.0
if value:
return value
# Check if the mapped field exists on the product template
if self.product_tmpl_id and field_name in self.product_tmpl_id._fields:
value = getattr(self.product_tmpl_id, field_name, 0.0) or 0.0
if value:
return value
return 0.0
def is_non_adp_funded(self):
"""
Check if this product has a NON-ADP, NON-FUNDED, or UNFUNDED device code.
Products with these device codes are not covered by ADP and should have:
- ADP portion = 0
- Client portion = full amount
- NOT included in ADP invoices (only in client invoices)
Returns True if the product is NOT funded by ADP.
"""
self.ensure_one()
# Get the ADP device code
adp_code = self.get_adp_device_code()
if not adp_code:
return False
# Check for non-funded codes (case-insensitive)
# These product codes indicate items NOT covered by ADP funding:
# - NON-ADP, NON-FUNDED, UNFUNDED: Explicitly not ADP funded
# - ACS: Accessibility items (client pays 100%)
# - ODS: ODSP items (client pays 100%)
# - OWP: Ontario Works items (client pays 100%)
non_funded_codes = [
'NON-ADP', 'NON ADP', 'NONADP',
'NON-FUNDED', 'NON FUNDED', 'NONFUNDED',
'UNFUNDED', 'NOT-FUNDED', 'NOT FUNDED', 'NOTFUNDED',
'ACS', 'ODS', 'OWP'
]
adp_code_upper = adp_code.upper().strip()
for non_funded in non_funded_codes:
if adp_code_upper == non_funded or adp_code_upper.startswith(non_funded):
return True
return False
def action_sync_adp_price_from_database(self):
"""
Update product's ADP price from the device codes database.
Looks up the product's ADP device code in the fusion.adp.device.code table
and updates the product's x_fc_adp_price field with the database value.
Returns a notification with the result.
"""
ADPDevice = self.env['fusion.adp.device.code'].sudo()
updated = []
not_found = []
no_code = []
for product in self:
device_code = product.get_adp_device_code()
if not device_code:
no_code.append(product.name)
continue
adp_device = ADPDevice.search([
('device_code', '=', device_code),
('active', '=', True)
], limit=1)
if adp_device and adp_device.adp_price:
# Update product template
product_tmpl = product.product_tmpl_id
old_price = 0
if hasattr(product_tmpl, 'x_fc_adp_price'):
old_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
product_tmpl.sudo().write({'x_fc_adp_price': adp_device.adp_price})
updated.append({
'name': product.name,
'code': device_code,
'old_price': old_price,
'new_price': adp_device.adp_price,
})
else:
not_found.append(f"{product.name} ({device_code})")
# Build result message
message_parts = []
if updated:
msg = f"<strong>Updated {len(updated)} product(s):</strong><ul>"
for u in updated:
msg += f"<li>{u['name']}: ${u['old_price']:.2f} → ${u['new_price']:.2f}</li>"
msg += "</ul>"
message_parts.append(msg)
if not_found:
message_parts.append(f"<strong>Not found in database:</strong> {', '.join(not_found)}")
if no_code:
message_parts.append(f"<strong>No ADP code:</strong> {', '.join(no_code)}")
if not message_parts:
message_parts.append("No products to process.")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'ADP Price Sync',
'message': '<br/>'.join(message_parts),
'type': 'success' if updated else 'warning',
'sticky': True,
}
}

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
# ==========================================================================
# ADP PRODUCT FIELDS
# These are the module's own fields - independent of Odoo Studio
# ==========================================================================
x_fc_adp_device_code = fields.Char(
string='ADP Device Code',
help='Device code used for ADP claims export',
copy=True,
tracking=True,
)
x_fc_adp_price = fields.Float(
string='ADP Price',
digits='Product Price',
help='ADP retail price for this product. Used in ADP reports and claims.',
copy=True,
tracking=True,
)
x_fc_is_adp_product = fields.Boolean(
string='Is ADP Product',
compute='_compute_is_adp_product',
store=True,
help='Indicates if this product has ADP pricing set up',
)
# ==========================================================================
# LOANER PRODUCT FIELDS
# ==========================================================================
x_fc_can_be_loaned = fields.Boolean(
string='Can be Loaned',
default=False,
help='If checked, this product can be loaned out to clients',
)
x_fc_loaner_period_days = fields.Integer(
string='Loaner Period (Days)',
default=7,
help='Default number of free loaner days before rental conversion',
)
x_fc_rental_price_weekly = fields.Float(
string='Weekly Rental Price',
digits='Product Price',
help='Rental price per week if loaner converts to rental',
)
x_fc_rental_price_monthly = fields.Float(
string='Monthly Rental Price',
digits='Product Price',
help='Rental price per month if loaner converts to rental',
)
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================
@api.depends('x_fc_adp_device_code', 'x_fc_adp_price')
def _compute_is_adp_product(self):
"""Determine if this is an ADP product based on having device code or price."""
for product in self:
product.x_fc_is_adp_product = bool(
product.x_fc_adp_device_code or product.x_fc_adp_price
)
# ==========================================================================
# HELPER METHODS
# ==========================================================================
def get_adp_price(self):
"""
Get ADP price with fallback to Studio field.
Checks in order:
1. x_fc_adp_price (module field)
2. list_price (default product price)
"""
self.ensure_one()
if self.x_fc_adp_price:
return self.x_fc_adp_price
return self.list_price or 0.0
def get_adp_device_code(self):
"""
Get ADP device code.
Checks in order:
1. x_fc_adp_device_code (module field)
2. default_code (internal reference)
"""
self.ensure_one()
if self.x_fc_adp_device_code:
return self.x_fc_adp_device_code
return self.default_code or ''

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Web Push Subscription model for storing browser push notification subscriptions.
"""
from odoo import models, fields, api
import logging
_logger = logging.getLogger(__name__)
class FusionPushSubscription(models.Model):
_name = 'fusion.push.subscription'
_description = 'Web Push Subscription'
_order = 'create_date desc'
user_id = fields.Many2one(
'res.users',
string='User',
required=True,
ondelete='cascade',
index=True,
)
endpoint = fields.Text(
string='Endpoint URL',
required=True,
)
p256dh_key = fields.Text(
string='P256DH Key',
required=True,
)
auth_key = fields.Text(
string='Auth Key',
required=True,
)
browser_info = fields.Char(
string='Browser Info',
help='User agent or browser identification',
)
active = fields.Boolean(
default=True,
)
_constraints = [
models.Constraint(
'unique(endpoint)',
'This push subscription endpoint already exists.',
),
]
@api.model
def register_subscription(self, user_id, endpoint, p256dh_key, auth_key, browser_info=None):
"""Register or update a push subscription."""
existing = self.sudo().search([('endpoint', '=', endpoint)], limit=1)
if existing:
existing.write({
'user_id': user_id,
'p256dh_key': p256dh_key,
'auth_key': auth_key,
'browser_info': browser_info or existing.browser_info,
'active': True,
})
return existing
return self.sudo().create({
'user_id': user_id,
'endpoint': endpoint,
'p256dh_key': p256dh_key,
'auth_key': auth_key,
'browser_info': browser_info,
})

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import models, fields
class ResCompany(models.Model):
_inherit = 'res.company'
# Store/Location Information
x_fc_store_address_1 = fields.Char(
string='Store Address Line 1',
help='First store/location address for reports (e.g., "Main Store - 123 Street, City, Province, Postal")',
)
x_fc_store_address_2 = fields.Char(
string='Store Address Line 2',
help='Second store/location address for reports (optional)',
)
x_fc_company_tagline = fields.Char(
string='Company Tagline',
help='Company tagline/slogan for reports (e.g., "Enhancing Accessibility, Improving Lives.")',
)
# Payment Information
x_fc_etransfer_email = fields.Char(
string='E-Transfer Email',
help='Email address for Interac e-Transfers',
)
x_fc_cheque_payable_to = fields.Char(
string='Cheque Payable To',
help='Name for cheque payments (defaults to company name if empty)',
)
x_fc_payment_terms_html = fields.Html(
string='Payment Terms',
help='Payment terms and conditions displayed on reports (supports HTML formatting)',
sanitize=True,
sanitize_overridable=True,
)
# Refund Policy
x_fc_include_refund_page = fields.Boolean(
string='Include Refund Policy Page',
default=True,
help='Include a separate refund policy page at the end of reports',
)
x_fc_refund_policy_html = fields.Html(
string='Refund Policy',
help='Full refund policy displayed on a separate page (supports HTML formatting)',
sanitize=True,
sanitize_overridable=True,
)
# Office Notification Recipients
x_fc_office_notification_ids = fields.Many2many(
'res.partner',
'fc_company_office_notification_partners_rel',
'company_id',
'partner_id',
string='Office Notification Recipients',
help='Contacts who will receive a copy (CC) of all automated ADP notifications',
)
def _get_cheque_payable_name(self):
"""Get the name for cheque payments, defaulting to company name."""
self.ensure_one()
return self.x_fc_cheque_payable_to or self.name

View File

@@ -0,0 +1,602 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# =========================================================================
# COMPANY SETTINGS (Related to res.company)
# =========================================================================
fc_store_address_1 = fields.Char(
related='company_id.x_fc_store_address_1',
readonly=False,
string='Store Address Line 1',
)
fc_store_address_2 = fields.Char(
related='company_id.x_fc_store_address_2',
readonly=False,
string='Store Address Line 2',
)
fc_company_tagline = fields.Char(
related='company_id.x_fc_company_tagline',
readonly=False,
string='Company Tagline',
)
fc_etransfer_email = fields.Char(
related='company_id.x_fc_etransfer_email',
readonly=False,
string='E-Transfer Email',
)
fc_cheque_payable_to = fields.Char(
related='company_id.x_fc_cheque_payable_to',
readonly=False,
string='Cheque Payable To',
)
fc_payment_terms_html = fields.Html(
related='company_id.x_fc_payment_terms_html',
readonly=False,
string='Payment Terms',
)
fc_include_refund_page = fields.Boolean(
related='company_id.x_fc_include_refund_page',
readonly=False,
string='Include Refund Policy Page',
)
fc_refund_policy_html = fields.Html(
related='company_id.x_fc_refund_policy_html',
readonly=False,
string='Refund Policy',
)
# =========================================================================
# ADP BILLING SETTINGS
# =========================================================================
fc_vendor_code = fields.Char(
string='ADP Vendor Code',
config_parameter='fusion_claims.vendor_code',
help='Your ADP vendor/location code for claim submissions',
)
# =========================================================================
# FIELD MAPPINGS
# =========================================================================
fc_field_sale_type = fields.Char(
string='Sale Type Field',
config_parameter='fusion_claims.field_sale_type',
help='Field name for sale type on sale.order',
)
fc_field_so_client_type = fields.Char(
string='SO Client Type Field',
config_parameter='fusion_claims.field_so_client_type',
help='Field name for client type on sale.order',
)
fc_field_so_authorizer = fields.Char(
string='SO Authorizer Field',
config_parameter='fusion_claims.field_so_authorizer',
help='Field name for authorizer on sale.order',
)
fc_field_invoice_type = fields.Char(
string='Invoice Type Field',
config_parameter='fusion_claims.field_invoice_type',
help='Field name for invoice type on account.move',
)
fc_field_inv_client_type = fields.Char(
string='Invoice Client Type Field',
config_parameter='fusion_claims.field_inv_client_type',
help='Field name for client type on account.move',
)
fc_field_inv_authorizer = fields.Char(
string='Invoice Authorizer Field',
config_parameter='fusion_claims.field_inv_authorizer',
help='Field name for authorizer on account.move',
)
fc_field_product_code = fields.Char(
string='Product ADP Code Field',
config_parameter='fusion_claims.field_product_code',
help='Field name for ADP device code on product.template',
)
fc_field_sol_serial = fields.Char(
string='SO Line Serial Field',
config_parameter='fusion_claims.field_sol_serial',
help='Field name for serial number on sale.order.line',
)
fc_field_aml_serial = fields.Char(
string='Invoice Line Serial Field',
config_parameter='fusion_claims.field_aml_serial',
help='Field name for serial number on account.move.line',
)
# =========================================================================
# ADDITIONAL SALE ORDER FIELD MAPPINGS
# =========================================================================
fc_field_so_claim_number = fields.Char(
string='SO Claim Number Field',
config_parameter='fusion_claims.field_so_claim_number',
help='Field name for claim number on sale.order',
)
fc_field_so_client_ref_1 = fields.Char(
string='SO Client Ref 1 Field',
config_parameter='fusion_claims.field_so_client_ref_1',
help='Field name for client reference 1 on sale.order',
)
fc_field_so_client_ref_2 = fields.Char(
string='SO Client Ref 2 Field',
config_parameter='fusion_claims.field_so_client_ref_2',
help='Field name for client reference 2 on sale.order',
)
fc_field_so_delivery_date = fields.Char(
string='SO Delivery Date Field',
config_parameter='fusion_claims.field_so_delivery_date',
help='Field name for ADP delivery date on sale.order',
)
fc_field_so_adp_status = fields.Char(
string='SO ADP Status Field',
config_parameter='fusion_claims.field_so_adp_status',
help='Field name for ADP status on sale.order',
)
fc_field_so_service_start = fields.Char(
string='SO Service Start Date Field',
config_parameter='fusion_claims.field_so_service_start',
help='Field name for service start date on sale.order',
)
fc_field_so_service_end = fields.Char(
string='SO Service End Date Field',
config_parameter='fusion_claims.field_so_service_end',
help='Field name for service end date on sale.order',
)
# =========================================================================
# ADDITIONAL INVOICE FIELD MAPPINGS
# =========================================================================
fc_field_inv_claim_number = fields.Char(
string='Invoice Claim Number Field',
config_parameter='fusion_claims.field_inv_claim_number',
help='Field name for claim number on account.move',
)
fc_field_inv_client_ref_1 = fields.Char(
string='Invoice Client Ref 1 Field',
config_parameter='fusion_claims.field_inv_client_ref_1',
help='Field name for client reference 1 on account.move',
)
fc_field_inv_client_ref_2 = fields.Char(
string='Invoice Client Ref 2 Field',
config_parameter='fusion_claims.field_inv_client_ref_2',
help='Field name for client reference 2 on account.move',
)
fc_field_inv_delivery_date = fields.Char(
string='Invoice Delivery Date Field',
config_parameter='fusion_claims.field_inv_delivery_date',
help='Field name for ADP delivery date on account.move',
)
fc_field_inv_service_start = fields.Char(
string='Invoice Service Start Date Field',
config_parameter='fusion_claims.field_inv_service_start',
help='Field name for service start date on account.move',
)
fc_field_inv_service_end = fields.Char(
string='Invoice Service End Date Field',
config_parameter='fusion_claims.field_inv_service_end',
help='Field name for service end date on account.move',
)
# =========================================================================
# SALE ORDER LINE FIELD MAPPINGS
# =========================================================================
fc_field_sol_placement = fields.Char(
string='SO Line Placement Field',
config_parameter='fusion_claims.field_sol_placement',
help='Field name for device placement on sale.order.line',
)
# =========================================================================
# INVOICE LINE FIELD MAPPINGS
# =========================================================================
fc_field_aml_placement = fields.Char(
string='Invoice Line Placement Field',
config_parameter='fusion_claims.field_aml_placement',
help='Field name for device placement on account.move.line',
)
# =========================================================================
# PRODUCT FIELD MAPPINGS
# =========================================================================
fc_field_product_adp_price = fields.Char(
string='Product ADP Price Field',
config_parameter='fusion_claims.field_product_adp_price',
help='Field name for ADP price on product.template',
)
# =========================================================================
# HEADER-LEVEL SERIAL NUMBER MAPPINGS
# =========================================================================
fc_field_so_primary_serial = fields.Char(
string='SO Primary Serial Field',
config_parameter='fusion_claims.field_so_primary_serial',
help='Field name for primary serial number on sale.order (header level)',
)
fc_field_inv_primary_serial = fields.Char(
string='Invoice Primary Serial Field',
config_parameter='fusion_claims.field_inv_primary_serial',
help='Field name for primary serial number on account.move (header level)',
)
# =========================================================================
# ADP POSTING SCHEDULE SETTINGS
# =========================================================================
fc_adp_posting_base_date = fields.Char(
string='ADP Posting Base Date',
config_parameter='fusion_claims.adp_posting_base_date',
help='Reference date for calculating bi-weekly posting schedule (a known posting day). Format: YYYY-MM-DD',
)
fc_adp_posting_frequency_days = fields.Integer(
string='Posting Frequency (Days)',
config_parameter='fusion_claims.adp_posting_frequency_days',
help='Number of days between ADP posting cycles (typically 14 days)',
)
fc_adp_billing_reminder_user_id = fields.Many2one(
'res.users',
string='Billing Deadline Reminder Person',
# NOTE: stored manually via get_values/set_values (not config_parameter)
# because Many2one + config_parameter causes double-write conflicts
help='Person to remind on Monday to complete ADP billing by Wednesday 6 PM',
)
fc_adp_correction_reminder_user_ids = fields.Many2many(
'res.users',
'fc_config_correction_reminder_users_rel',
'config_id',
'user_id',
string='Correction Alert Recipients',
help='People to notify when an ADP invoice needs correction/resubmission',
)
# =========================================================================
# EMAIL NOTIFICATION SETTINGS
# =========================================================================
fc_enable_email_notifications = fields.Boolean(
string='Enable Automated Email Notifications',
config_parameter='fusion_claims.enable_email_notifications',
help='Enable/disable automated email notifications for ADP workflow events',
)
fc_office_notification_ids = fields.Many2many(
related='company_id.x_fc_office_notification_ids',
readonly=False,
string='Office Notification Recipients',
)
fc_application_reminder_days = fields.Integer(
string='First Reminder Days',
config_parameter='fusion_claims.application_reminder_days',
help='Number of days after assessment completion to send first application reminder to therapist',
)
fc_application_reminder_2_days = fields.Integer(
string='Second Reminder Days (After First)',
config_parameter='fusion_claims.application_reminder_2_days',
help='Number of days after first reminder to send second application reminder to therapist',
)
# =========================================================================
# WORKFLOW LOCK SETTINGS
# =========================================================================
fc_allow_sale_type_override = fields.Boolean(
string='Allow Sale Type Override',
config_parameter='fusion_claims.allow_sale_type_override',
help='If enabled, allows changing Sale Type even after application is submitted (for cases where additional benefits are discovered)',
)
fc_allow_document_lock_override = fields.Boolean(
string='Allow Document Lock Override',
config_parameter='fusion_claims.allow_document_lock_override',
help='When enabled, users in the "Document Lock Override" group can edit locked documents on old cases. '
'Disable this once all legacy cases have been processed to enforce strict workflow.',
)
fc_designated_vendor_signer = fields.Many2one(
'res.users',
string='Designated Vendor Signer',
help='The user who signs Page 12 on behalf of the company',
)
# =========================================================================
# GOOGLE MAPS API SETTINGS
# =========================================================================
fc_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_claims.google_maps_api_key',
help='API key for Google Maps Places autocomplete in address fields',
)
# ------------------------------------------------------------------
# AI CLIENT INTELLIGENCE
# ------------------------------------------------------------------
fc_ai_api_key = fields.Char(
string='AI API Key',
config_parameter='fusion_claims.ai_api_key',
help='OpenAI API key for Client Intelligence chat',
)
fc_ai_model = fields.Selection([
('gpt-4o-mini', 'GPT-4o Mini (Fast, Lower Cost)'),
('gpt-4o', 'GPT-4o (Best Quality)'),
('gpt-4.1-mini', 'GPT-4.1 Mini'),
('gpt-4.1', 'GPT-4.1'),
], string='AI Model',
config_parameter='fusion_claims.ai_model',
)
fc_auto_parse_xml = fields.Boolean(
string='Auto-Parse XML Files',
config_parameter='fusion_claims.auto_parse_xml',
help='Automatically parse ADP XML files when uploaded and create/update client profiles',
)
# ------------------------------------------------------------------
# TECHNICIAN MANAGEMENT
# ------------------------------------------------------------------
fc_store_open_hour = fields.Float(
string='Store Open Time',
config_parameter='fusion_claims.store_open_hour',
help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
)
fc_store_close_hour = fields.Float(
string='Store Close Time',
config_parameter='fusion_claims.store_close_hour',
help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
)
fc_google_distance_matrix_enabled = fields.Boolean(
string='Enable Distance Matrix',
config_parameter='fusion_claims.google_distance_matrix_enabled',
help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
)
fc_technician_start_address = fields.Char(
string='Technician Start Address',
config_parameter='fusion_claims.technician_start_address',
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
)
fc_location_retention_days = fields.Char(
string='Location History Retention (Days)',
config_parameter='fusion_claims.location_retention_days',
help='How many days to keep technician location history. '
'Leave empty = 30 days (1 month). '
'0 = delete at end of each day. '
'1+ = keep for that many days.',
)
# ------------------------------------------------------------------
# WEB PUSH NOTIFICATIONS
# ------------------------------------------------------------------
fc_push_enabled = fields.Boolean(
string='Enable Push Notifications',
config_parameter='fusion_claims.push_enabled',
help='Enable web push notifications for technician tasks',
)
fc_vapid_public_key = fields.Char(
string='VAPID Public Key',
config_parameter='fusion_claims.vapid_public_key',
help='Public key for Web Push VAPID authentication (auto-generated)',
)
fc_vapid_private_key = fields.Char(
string='VAPID Private Key',
config_parameter='fusion_claims.vapid_private_key',
help='Private key for Web Push VAPID authentication (auto-generated)',
)
fc_push_advance_minutes = fields.Integer(
string='Notification Advance (min)',
config_parameter='fusion_claims.push_advance_minutes',
help='Send push notifications this many minutes before a scheduled task',
)
# ------------------------------------------------------------------
# TWILIO SMS SETTINGS
# ------------------------------------------------------------------
fc_twilio_enabled = fields.Boolean(
string='Enable Twilio SMS',
config_parameter='fusion_claims.twilio_enabled',
help='Enable SMS notifications via Twilio for assessment bookings and key status updates',
)
fc_twilio_account_sid = fields.Char(
string='Twilio Account SID',
config_parameter='fusion_claims.twilio_account_sid',
groups='fusion_claims.group_fusion_claims_manager',
)
fc_twilio_auth_token = fields.Char(
string='Twilio Auth Token',
config_parameter='fusion_claims.twilio_auth_token',
groups='fusion_claims.group_fusion_claims_manager',
)
fc_twilio_phone_number = fields.Char(
string='Twilio Phone Number',
config_parameter='fusion_claims.twilio_phone_number',
help='Your Twilio phone number for sending SMS (e.g. +1234567890)',
)
# ------------------------------------------------------------------
# MARCH OF DIMES SETTINGS
# ------------------------------------------------------------------
fc_mod_default_email = fields.Char(
string='MOD Default Email',
config_parameter='fusion_claims.mod_default_email',
help='Default email for sending quotations and documents to March of Dimes (e.g. hvmp@marchofdimes.ca)',
)
fc_mod_vendor_code = fields.Char(
string='March of Dimes Vendor Code',
config_parameter='fusion_claims.mod_vendor_code',
help='Your vendor code assigned by March of Dimes (e.g. TRD0001234)',
)
# ------------------------------------------------------------------
# MOD FOLLOW-UP SETTINGS
# ------------------------------------------------------------------
fc_mod_followup_interval_days = fields.Integer(
string='Follow-up Interval (Days)',
config_parameter='fusion_claims.mod_followup_interval_days',
help='Number of days between follow-up reminders for MOD cases awaiting funding (default: 14)',
)
fc_mod_followup_escalation_days = fields.Integer(
string='Escalation Delay (Days)',
config_parameter='fusion_claims.mod_followup_escalation_days',
help='Days after a follow-up activity is due before auto-sending email to client (default: 3)',
)
# ------------------------------------------------------------------
# ODSP CONFIGURATION
# ------------------------------------------------------------------
fc_sa_mobility_email = fields.Char(
string='SA Mobility Email',
config_parameter='fusion_claims.sa_mobility_email',
help='Email address for SA Mobility submissions (default: samobility@ontario.ca)',
)
fc_sa_mobility_phone = fields.Char(
string='SA Mobility Phone',
config_parameter='fusion_claims.sa_mobility_phone',
help='SA Mobility phone number (default: 1-888-222-5099)',
)
fc_odsp_default_office_id = fields.Many2one(
'res.partner',
string='Default ODSP Office',
domain="[('x_fc_contact_type', '=', 'odsp_office')]",
help='Default ODSP office contact for new ODSP cases',
)
@api.model
def get_values(self):
res = super().get_values()
ICP = self.env['ir.config_parameter'].sudo()
# Get billing reminder user
billing_user_id = ICP.get_param('fusion_claims.adp_billing_reminder_user_id', False)
if billing_user_id:
try:
res['fc_adp_billing_reminder_user_id'] = int(billing_user_id)
except (ValueError, TypeError):
res['fc_adp_billing_reminder_user_id'] = False
# Get correction reminder users (stored as comma-separated IDs)
correction_user_ids = ICP.get_param('fusion_claims.adp_correction_reminder_user_ids', '')
if correction_user_ids:
try:
user_ids = [int(x.strip()) for x in correction_user_ids.split(',') if x.strip()]
res['fc_adp_correction_reminder_user_ids'] = [(6, 0, user_ids)]
except (ValueError, TypeError):
res['fc_adp_correction_reminder_user_ids'] = [(6, 0, [])]
# Get designated vendor signer
vendor_signer_id = ICP.get_param('fusion_claims.designated_vendor_signer', False)
if vendor_signer_id:
try:
res['fc_designated_vendor_signer'] = int(vendor_signer_id)
except (ValueError, TypeError):
res['fc_designated_vendor_signer'] = False
# Get default ODSP office
odsp_office_id = ICP.get_param('fusion_claims.odsp_default_office_id', False)
if odsp_office_id:
try:
res['fc_odsp_default_office_id'] = int(odsp_office_id)
except (ValueError, TypeError):
res['fc_odsp_default_office_id'] = False
return res
def set_values(self):
ICP = self.env['ir.config_parameter'].sudo()
# --- Protect sensitive config_parameter fields from accidental blanking ---
# These are keys where a blank/default value should NOT overwrite
# an existing non-empty value (e.g. API keys, user-customized settings).
_protected_keys = [
'fusion_claims.ai_api_key',
'fusion_claims.google_maps_api_key',
'fusion_claims.vendor_code',
'fusion_claims.ai_model',
'fusion_claims.adp_posting_base_date',
'fusion_claims.application_reminder_days',
'fusion_claims.application_reminder_2_days',
'fusion_claims.store_open_hour',
'fusion_claims.store_close_hour',
'fusion_claims.technician_start_address',
]
# Snapshot existing values BEFORE super().set_values() runs
_existing = {}
for key in _protected_keys:
val = ICP.get_param(key, '')
if val:
_existing[key] = val
super().set_values()
# Restore any protected values that were blanked by the save
for key, old_val in _existing.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 (was blanked during save)", key
)
# Store billing reminder user (Many2one - manual handling)
if self.fc_adp_billing_reminder_user_id:
ICP.set_param('fusion_claims.adp_billing_reminder_user_id',
str(self.fc_adp_billing_reminder_user_id.id))
# Only clear if explicitly set to empty AND there was no existing value
elif not ICP.get_param('fusion_claims.adp_billing_reminder_user_id', ''):
ICP.set_param('fusion_claims.adp_billing_reminder_user_id', '')
# Store correction reminder users as comma-separated IDs
if self.fc_adp_correction_reminder_user_ids:
user_ids = ','.join(str(u.id) for u in self.fc_adp_correction_reminder_user_ids)
ICP.set_param('fusion_claims.adp_correction_reminder_user_ids', user_ids)
# Only clear if explicitly empty AND no existing value
elif not ICP.get_param('fusion_claims.adp_correction_reminder_user_ids', ''):
ICP.set_param('fusion_claims.adp_correction_reminder_user_ids', '')
# Office notification recipients are stored via related field on res.company
# No need to store in ir.config_parameter
# Store designated vendor signer (Many2one - manual handling)
if self.fc_designated_vendor_signer:
ICP.set_param('fusion_claims.designated_vendor_signer',
str(self.fc_designated_vendor_signer.id))
elif not ICP.get_param('fusion_claims.designated_vendor_signer', ''):
ICP.set_param('fusion_claims.designated_vendor_signer', '')
# Store default ODSP office (Many2one - manual handling)
if self.fc_odsp_default_office_id:
ICP.set_param('fusion_claims.odsp_default_office_id',
str(self.fc_odsp_default_office_id.id))
elif not ICP.get_param('fusion_claims.odsp_default_office_id', ''):
ICP.set_param('fusion_claims.odsp_default_office_id', '')
# =========================================================================
# ACTION METHODS
# =========================================================================
def action_open_field_mapping_wizard(self):
"""Open the field mapping configuration wizard."""
return {
'type': 'ir.actions.act_window',
'name': 'Field Mapping Configuration',
'res_model': 'fusion_claims.field_mapping_config',
'view_mode': 'form',
'target': 'new',
'context': {},
}

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class ResPartner(models.Model):
_inherit = 'res.partner'
x_fc_start_address = fields.Char(
string='Start Location',
help='Technician daily start location (home, warehouse, etc.). '
'Used as origin for first travel time calculation. '
'If empty, the company default HQ address is used.',
)
# ==========================================================================
# CONTACT TYPE
# ==========================================================================
x_fc_contact_type = fields.Selection(
selection=[
('adp_customer', 'ADP Customer'),
('adp_odsp_customer', 'ADP-ODSP Customer'),
('odsp_customer', 'ODSP Customer'),
('mod_customer', 'MOD Customer'),
('private_customer', 'Private Customer'),
('wsib_customer', 'WSIB Customer'),
('acsd_customer', 'ACSD Customer'),
('private_insurance', 'Private Insurance'),
('adp_agent', 'ADP Agent'),
('odsp_agent', 'ODSP Agent'),
('muscular_dystrophy', 'Muscular Dystrophy'),
('occupational_therapist', 'Occupational Therapist'),
('physiotherapist', 'Physiotherapist'),
('vendor', 'Vendor'),
('funding_agency', 'Funding Agency'),
('government_agency', 'Government Agency'),
('company_contact', 'Company Contact'),
('long_term_care_home', 'Long Term Care Home'),
('retirement_home', 'Retirement Home'),
('odsp_office', 'ODSP Office'),
('other', 'Other'),
],
string='Contact Type',
tracking=True,
index=True,
)
# ==========================================================================
# ODSP FIELDS
# ==========================================================================
x_fc_odsp_member_id = fields.Char(
string='ODSP Member ID',
size=9,
tracking=True,
help='9-digit Ontario Disability Support Program Member ID',
)
x_fc_case_worker_id = fields.Many2one(
'res.partner',
string='ODSP Case Worker',
tracking=True,
help='ODSP Case Worker assigned to this client',
)
x_fc_date_of_birth = fields.Date(
string='Date of Birth',
tracking=True,
)
x_fc_healthcard_number = fields.Char(
string='Healthcard Number',
tracking=True,
)
x_fc_is_odsp_office = fields.Boolean(
compute='_compute_is_odsp_office',
string='Is ODSP Office',
store=True,
)
@api.depends('x_fc_contact_type')
def _compute_is_odsp_office(self):
for partner in self:
partner.x_fc_is_odsp_office = partner.x_fc_contact_type == 'odsp_office'

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_is_field_staff = fields.Boolean(
string='Field Staff',
default=False,
help='Check this to show the user in the Technician/Field Staff dropdown when scheduling tasks.',
)
x_fc_start_address = fields.Char(
related='partner_id.x_fc_start_address',
readonly=False,
string='Start Location',
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import models, fields, api
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
# ==========================================================================
# PARENT FIELD FOR VISIBILITY (used by Studio conditions)
# ==========================================================================
x_fc_is_adp_sale = fields.Boolean(
compute='_compute_is_adp_sale_line',
string='Is ADP Sale',
store=False,
help='True if parent order is an ADP sale - used for column visibility',
)
def _compute_is_adp_sale_line(self):
"""Check if parent order is an ADP sale."""
for line in self:
is_adp = False
if line.order_id and line.order_id.x_fc_sale_type:
is_adp = 'adp' in str(line.order_id.x_fc_sale_type).lower()
line.x_fc_is_adp_sale = is_adp
@api.depends('product_id', 'product_id.default_code')
def _compute_adp_device_type(self):
"""Compute ADP device type from the product's device code."""
ADPDevice = self.env['fusion.adp.device.code'].sudo()
for line in self:
device_type = ''
if line.product_id:
# Get the device code from product (default_code or custom field)
device_code = line._get_adp_device_code()
if device_code:
# Look up device type in ADP database
adp_device = ADPDevice.search([
('device_code', '=', device_code),
('active', '=', True)
], limit=1)
if adp_device:
device_type = adp_device.device_type or ''
line.x_fc_adp_device_type = device_type
# ==========================================================================
# SERIAL NUMBER AND DEVICE PLACEMENT
# ==========================================================================
x_fc_serial_number = fields.Char(
string='Serial Number',
help='Serial number for this product',
)
x_fc_device_placement = fields.Selection(
selection=[
('L', 'Left'),
('R', 'Right'),
('NA', 'N/A'),
],
string='Device Placement',
default='NA',
help='Device placement position (Left/Right/N/A)',
)
# ==========================================================================
# DEDUCTION FIELDS
# ==========================================================================
x_fc_deduction_type = fields.Selection(
selection=[
('none', 'No Deduction'),
('pct', 'Percentage'),
('amt', 'Amount'),
],
string='Deduction Type',
default='none',
help='Type of ADP deduction applied to this line',
)
x_fc_deduction_value = fields.Float(
string='Deduction Value',
digits='Product Price',
help='Deduction value (percentage if PCT, dollar amount if AMT)',
)
# ==========================================================================
# ADP REFERENCE FIELDS
# ==========================================================================
x_fc_adp_max_price = fields.Float(
string='ADP Max Price',
digits='Product Price',
help='Maximum price ADP will cover for this device (from mobility manual)',
)
x_fc_sn_required = fields.Boolean(
string='S/N Required',
help='Is serial number required for this device?',
)
# ==========================================================================
# ADP DEVICE APPROVAL TRACKING
# ==========================================================================
x_fc_adp_approved = fields.Boolean(
string='ADP Approved',
default=False,
help='Was this device approved by ADP in the application approval?',
)
x_fc_adp_device_type = fields.Char(
string='ADP Device Type',
compute='_compute_adp_device_type',
store=True,
help='Device type from ADP mobility manual (for approval matching)',
)
# ==========================================================================
# COMPUTED ADP PORTIONS
# ==========================================================================
x_fc_adp_portion = fields.Monetary(
string='ADP Portion',
compute='_compute_adp_portions',
store=True,
currency_field='currency_id',
help='ADP portion for this line',
)
x_fc_client_portion = fields.Monetary(
string='Client Portion',
compute='_compute_adp_portions',
store=True,
currency_field='currency_id',
help='Client portion for this line',
)
# ==========================================================================
# COMPUTE ADP PORTIONS WITH DEDUCTIONS AND APPROVAL STATUS
# ==========================================================================
@api.depends('price_subtotal', 'product_uom_qty', 'price_unit', 'product_id',
'order_id.x_fc_sale_type', 'order_id.x_fc_client_type',
'order_id.x_fc_device_verification_complete',
'x_fc_deduction_type', 'x_fc_deduction_value', 'x_fc_adp_max_price',
'x_fc_adp_approved')
def _compute_adp_portions(self):
"""Compute ADP and client portions based on product's ADP price, client type, and approval status.
IMPORTANT:
1. If a product has NON-ADP code (NON-ADP, NON-FUNDED, etc.): Client pays 100%
2. If a product is NOT in the ADP device database: Client pays 100%
3. If a device is NOT approved by ADP: Client pays 100%
4. Only products with valid ADP codes that are approved get the 75%/25% (or 100%/0%) split
"""
ADPDevice = self.env['fusion.adp.device.code'].sudo()
for line in self:
# Get sale type and client type from parent order
order = line.order_id
if not order:
line.x_fc_adp_portion = 0
line.x_fc_client_portion = 0
continue
# Check if this is an ADP sale
if not order._is_adp_sale():
line.x_fc_adp_portion = 0
line.x_fc_client_portion = 0
continue
# Skip non-product lines
if not line.product_id or line.product_uom_qty <= 0:
line.x_fc_adp_portion = 0
line.x_fc_client_portion = 0
continue
# =================================================================
# CHECK 1: Is this a NON-ADP funded product?
# Products with NON-ADP, NON-FUNDED, UNFUNDED codes = 100% client
# =================================================================
if line.product_id.is_non_adp_funded():
line.x_fc_adp_portion = 0
line.x_fc_client_portion = line.price_subtotal
continue
# =================================================================
# CHECK 2: Does this product have a valid ADP device code?
# Products without valid ADP codes in the database = 100% client
# =================================================================
device_code = line._get_adp_device_code()
is_adp_device = False
if device_code:
# Check if this code exists in the ADP mobility manual database
is_adp_device = ADPDevice.search_count([
('device_code', '=', device_code),
('active', '=', True)
]) > 0
# If product has NO valid ADP code in database: client pays 100%
if not is_adp_device:
line.x_fc_adp_portion = 0
line.x_fc_client_portion = line.price_subtotal
continue
# =================================================================
# CHECK 3: If this is an ADP device but NOT approved: 100% client
# =================================================================
if order.x_fc_device_verification_complete and not line.x_fc_adp_approved:
line.x_fc_adp_portion = 0
line.x_fc_client_portion = line.price_subtotal
continue
# =================================================================
# STANDARD CALCULATION: Product is a valid, approved ADP device
# =================================================================
# Get client type and determine base percentages
client_type = order._get_client_type()
if client_type == 'REG':
# REG: 75% ADP, 25% Client
base_adp_pct = 0.75
base_client_pct = 0.25
else:
# ODS, OWP, ACS, LTC, SEN, CCA: 100% ADP, 0% Client
base_adp_pct = 1.0
base_client_pct = 0.0
# Get the ADP price from the product
adp_price = 0
if line.product_id and line.product_id.product_tmpl_id:
product_tmpl = line.product_id.product_tmpl_id
if hasattr(product_tmpl, 'x_fc_adp_price'):
adp_price = product_tmpl.x_fc_adp_price or 0
if not adp_price and line.x_fc_adp_max_price:
adp_price = line.x_fc_adp_max_price
if not adp_price:
adp_price = line.price_unit
qty = line.product_uom_qty
adp_base_total = adp_price * qty
# Apply deductions
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
# PCT: ADP only covers deduction_value% of their portion
effective_adp_pct = base_adp_pct * (line.x_fc_deduction_value / 100)
effective_client_pct = 1 - effective_adp_pct
adp_portion = adp_base_total * effective_adp_pct
client_portion = adp_base_total * effective_client_pct
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
# AMT: Subtract fixed amount from ADP portion
base_adp_amount = adp_base_total * base_adp_pct
adp_portion = max(0, base_adp_amount - line.x_fc_deduction_value)
client_portion = adp_base_total - adp_portion
else:
# No deduction - standard calculation based on ADP price
adp_portion = adp_base_total * base_adp_pct
client_portion = adp_base_total * base_client_pct
line.x_fc_adp_portion = adp_portion
line.x_fc_client_portion = client_portion
# ==========================================================================
# GETTER METHODS
# ==========================================================================
def _get_adp_device_code(self):
"""Get ADP device code from product.
Checks multiple sources in order:
1. x_fc_adp_device_code (module field)
2. x_adp_code (Studio/custom field)
3. default_code
4. Code in parentheses in product name (e.g., "Product Name (SE0001109)")
"""
import re
self.ensure_one()
if not self.product_id:
return ''
product_tmpl = self.product_id.product_tmpl_id
ADPDevice = self.env['fusion.adp.device.code'].sudo()
# 1. Check x_fc_adp_device_code (module field)
code = ''
if hasattr(product_tmpl, 'x_fc_adp_device_code'):
code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
# Verify code exists in ADP database
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
# 2. Check x_adp_code (Studio/custom field)
if hasattr(product_tmpl, 'x_adp_code'):
code = getattr(product_tmpl, 'x_adp_code', '') or ''
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
# 3. Check default_code
code = self.product_id.default_code or ''
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
return code
# 4. Try to extract code from product name in parentheses
# E.g., "[MXA-1618] GEOMATRIX SILVERBACK MAX BACKREST - ACTIVE (SE0001109)"
product_name = self.product_id.name or ''
# Find all codes in parentheses
matches = re.findall(r'\(([A-Z0-9]+)\)', product_name)
for potential_code in matches:
if ADPDevice.search_count([('device_code', '=', potential_code), ('active', '=', True)]) > 0:
return potential_code
# 5. Final fallback - return default_code even if not in ADP database
return self.product_id.default_code or ''
def _get_serial_number(self):
"""Get serial number from mapped field or native field."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_sol_serial', 'x_fc_serial_number')
# Try mapped field first
if hasattr(self, field_name):
value = getattr(self, field_name, None)
if value:
return value
# Fallback to native field
return self.x_fc_serial_number or ''
def _get_device_placement(self):
"""Get device placement."""
self.ensure_one()
return self.x_fc_device_placement or 'NA'
# ==========================================================================
# INVOICE LINE PREPARATION
# ==========================================================================
def _prepare_invoice_line(self, **optional_values):
"""Override to copy ADP line fields to the invoice line."""
vals = super()._prepare_invoice_line(**optional_values)
vals.update({
'x_fc_serial_number': self.x_fc_serial_number,
'x_fc_device_placement': self.x_fc_device_placement,
'x_fc_deduction_type': self.x_fc_deduction_type,
'x_fc_deduction_value': self.x_fc_deduction_value,
'x_fc_adp_max_price': self.x_fc_adp_max_price,
'x_fc_sn_required': self.x_fc_sn_required,
'x_fc_adp_approved': self.x_fc_adp_approved,
'x_fc_adp_device_type': self.x_fc_adp_device_type,
})
return vals
# ==========================================================================
# ONCHANGE FOR ADP MAX PRICE LOOKUP
# ==========================================================================
@api.onchange('product_id')
def _onchange_product_adp_info(self):
"""Lookup ADP info from device codes when product changes."""
if self.product_id:
# Try to find device code in the reference table
device_code = self._get_adp_device_code()
if device_code:
adp_device = self.env['fusion.adp.device.code'].sudo().search([
('device_code', '=', device_code)
], limit=1)
if adp_device:
self.x_fc_adp_max_price = adp_device.adp_price
self.x_fc_sn_required = adp_device.sn_required

View File

@@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import api, fields, models
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class FusionSubmissionHistory(models.Model):
"""Track submission history for ADP applications.
Each record represents one submission or resubmission to ADP,
including the documents submitted, the result, and any rejection reasons.
"""
_name = 'fusion.submission.history'
_description = 'ADP Submission History'
_order = 'submission_date desc, id desc'
_rec_name = 'display_name'
# ==========================================================================
# RELATIONSHIPS
# ==========================================================================
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
ondelete='cascade',
index=True,
)
# ==========================================================================
# SUBMISSION DETAILS
# ==========================================================================
display_name = fields.Char(
string='Display Name',
compute='_compute_display_name',
store=True,
)
submission_number = fields.Integer(
string='Submission #',
default=1,
help='Sequence number for this submission (1 = first submission, 2+ = resubmissions)',
)
submission_type = fields.Selection(
selection=[
('initial', 'Initial Submission'),
('resubmission', 'Resubmission'),
('correction', 'Correction'),
],
string='Type',
default='initial',
)
submission_date = fields.Date(
string='Submission Date',
default=fields.Date.today,
required=True,
)
submitted_by_id = fields.Many2one(
'res.users',
string='Submitted By',
default=lambda self: self.env.user,
)
# ==========================================================================
# DOCUMENTS SUBMITTED (copies at time of submission)
# ==========================================================================
final_application = fields.Binary(
string='Final Application (PDF)',
attachment=True,
help='Copy of the final application PDF at time of submission',
)
final_application_filename = fields.Char(
string='Final Application Filename',
)
xml_file = fields.Binary(
string='XML File',
attachment=True,
help='Copy of the XML file at time of submission',
)
xml_filename = fields.Char(
string='XML Filename',
)
# ==========================================================================
# RESULT TRACKING
# ==========================================================================
result = fields.Selection(
selection=[
('pending', 'Pending'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('approved', 'Approved'),
('denied', 'Denied'),
],
string='Result',
default='pending',
)
result_date = fields.Date(
string='Result Date',
help='Date when the result was received from ADP',
)
# ==========================================================================
# REJECTION DETAILS (if rejected)
# ==========================================================================
rejection_reason = fields.Selection(
selection=[
('name_correction', 'Name Correction Needed'),
('healthcard_correction', 'Health Card Correction Needed'),
('duplicate_claim', 'Duplicate Claim Exists'),
('xml_format_error', 'XML Format/Validation Error'),
('missing_info', 'Missing Required Information'),
('other', 'Other'),
],
string='Rejection Reason',
)
rejection_details = fields.Text(
string='Rejection Details',
help='Additional details about the rejection',
)
# ==========================================================================
# CORRECTION NOTES (for resubmissions)
# ==========================================================================
correction_notes = fields.Text(
string='Correction Notes',
help='Notes about what was corrected for this resubmission',
)
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================
@api.depends('sale_order_id', 'submission_number', 'submission_type')
def _compute_display_name(self):
for record in self:
order_name = record.sale_order_id.name or 'New'
type_label = dict(record._fields['submission_type'].selection).get(
record.submission_type, record.submission_type
)
record.display_name = f"{order_name} - Submission #{record.submission_number} ({type_label})"
# ==========================================================================
# HELPER METHODS
# ==========================================================================
@api.model
def create_from_submission(self, sale_order, submission_type='initial', correction_notes=None):
"""Create a submission history record from a sale order submission.
Args:
sale_order: The sale.order record being submitted
submission_type: 'initial', 'resubmission', or 'correction'
correction_notes: Optional notes about corrections made
Returns:
The created fusion.submission.history record
"""
# Get next submission number
existing_count = self.search_count([('sale_order_id', '=', sale_order.id)])
submission_number = existing_count + 1
# If submission_number > 1, it's a resubmission
if submission_number > 1 and submission_type == 'initial':
submission_type = 'resubmission'
vals = {
'sale_order_id': sale_order.id,
'submission_number': submission_number,
'submission_type': submission_type,
'submission_date': fields.Date.today(),
'submitted_by_id': self.env.user.id,
'correction_notes': correction_notes,
}
# Copy current documents
if sale_order.x_fc_final_submitted_application:
vals['final_application'] = sale_order.x_fc_final_submitted_application
vals['final_application_filename'] = sale_order.x_fc_final_application_filename
if sale_order.x_fc_xml_file:
vals['xml_file'] = sale_order.x_fc_xml_file
vals['xml_filename'] = sale_order.x_fc_xml_filename
record = self.create(vals)
# Post to chatter
sale_order.message_post(
body=Markup(
'<div style="border-left: 3px solid #3498db; padding-left: 12px; margin: 8px 0;">'
'<p style="margin: 0 0 8px 0; font-weight: 600; color: #2980b9;">'
f'<i class="fa fa-paper-plane"></i> Submission #{submission_number} Recorded</p>'
'<table style="font-size: 13px; color: #555;">'
f'<tr><td style="padding: 2px 8px 2px 0; font-weight: 500;">Type:</td><td>{dict(self._fields["submission_type"].selection).get(submission_type)}</td></tr>'
f'<tr><td style="padding: 2px 8px 2px 0; font-weight: 500;">Date:</td><td>{fields.Date.today().strftime("%B %d, %Y")}</td></tr>'
f'<tr><td style="padding: 2px 8px 2px 0; font-weight: 500;">By:</td><td>{self.env.user.name}</td></tr>'
'</table>'
+ (f'<p style="margin: 8px 0 0 0; font-size: 12px;"><strong>Corrections:</strong> {correction_notes}</p>' if correction_notes else '') +
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return record
def update_result(self, result, rejection_reason=None, rejection_details=None):
"""Update the result of a submission.
Args:
result: 'accepted', 'rejected', 'approved', or 'denied'
rejection_reason: Selection value for rejection reason
rejection_details: Text details for rejection
"""
self.ensure_one()
vals = {
'result': result,
'result_date': fields.Date.today(),
}
if result == 'rejected':
vals['rejection_reason'] = rejection_reason
vals['rejection_details'] = rejection_details
self.write(vals)
return self

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Fusion Technician Location
GPS location logging for field technicians.
"""
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class FusionTechnicianLocation(models.Model):
_name = 'fusion.technician.location'
_description = 'Technician Location Log'
_order = 'logged_at desc'
user_id = fields.Many2one(
'res.users',
string='Technician',
required=True,
index=True,
ondelete='cascade',
)
latitude = fields.Float(
string='Latitude',
digits=(10, 7),
required=True,
)
longitude = fields.Float(
string='Longitude',
digits=(10, 7),
required=True,
)
accuracy = fields.Float(
string='Accuracy (m)',
help='GPS accuracy in meters',
)
logged_at = fields.Datetime(
string='Logged At',
default=fields.Datetime.now,
required=True,
index=True,
)
source = fields.Selection([
('portal', 'Portal'),
('app', 'Mobile App'),
], string='Source', default='portal')
@api.model
def log_location(self, latitude, longitude, accuracy=None):
"""Log the current user's location. Called from portal JS."""
return self.sudo().create({
'user_id': self.env.user.id,
'latitude': latitude,
'longitude': longitude,
'accuracy': accuracy or 0,
'source': 'portal',
})
@api.model
def get_latest_locations(self):
"""Get the most recent location for each technician (for map view)."""
self.env.cr.execute("""
SELECT DISTINCT ON (user_id)
user_id, latitude, longitude, accuracy, logged_at
FROM fusion_technician_location
WHERE logged_at > NOW() - INTERVAL '24 hours'
ORDER BY user_id, logged_at DESC
""")
rows = self.env.cr.dictfetchall()
result = []
for row in rows:
user = self.env['res.users'].sudo().browse(row['user_id'])
result.append({
'user_id': row['user_id'],
'name': user.name,
'latitude': row['latitude'],
'longitude': row['longitude'],
'accuracy': row['accuracy'],
'logged_at': str(row['logged_at']),
})
return result
@api.model
def _cron_cleanup_old_locations(self):
"""Remove location logs based on configurable retention setting.
Setting (fusion_claims.location_retention_days):
- Empty / not set => keep 30 days (default)
- "0" => delete at end of day (keep today only)
- "1" .. "N" => keep for N days
"""
ICP = self.env['ir.config_parameter'].sudo()
raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip()
if raw == '':
retention_days = 30 # default: 1 month
else:
try:
retention_days = max(int(raw), 0)
except (ValueError, TypeError):
retention_days = 30
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days)
old_records = self.search([('logged_at', '<', cutoff)])
count = len(old_records)
if count:
old_records.unlink()
_logger.info(
"Cleaned up %d technician location records (retention=%d days)",
count, retention_days,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,735 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import json
import logging
import xml.etree.ElementTree as ET
from collections import OrderedDict
from datetime import datetime
from odoo import api, models
_logger = logging.getLogger(__name__)
class FusionXmlParser(models.AbstractModel):
"""Utility to parse ADP application XML files and create/update
client profiles and application data records.
Captures ALL ~300 XML fields for round-trip export fidelity.
"""
_name = 'fusion.xml.parser'
_description = 'ADP XML Parser'
# ------------------------------------------------------------------
# PUBLIC API
# ------------------------------------------------------------------
@api.model
def parse_from_binary(self, binary_data, sale_order=None):
"""Parse from binary field (base64 encoded).
Returns tuple (profile, application_data) or (False, False).
"""
if not binary_data:
return False, False
try:
xml_content = base64.b64decode(binary_data).decode('utf-8')
except Exception as e:
_logger.warning('Failed to decode XML binary: %s', e)
return False, False
return self.parse_and_create(xml_content, sale_order)
@api.model
def parse_and_create(self, xml_content, sale_order=None):
"""Parse raw XML string, create/update profile and application data.
Returns tuple (profile, application_data) or (False, False).
"""
try:
root = ET.fromstring(xml_content)
except ET.ParseError as e:
_logger.warning('Failed to parse ADP XML: %s', e)
return False, False
form = root.find('Form')
if form is None:
form = root
# Step 1: Build complete JSON dict (every field, dot-notation keys)
json_dict = self._xml_to_json(form)
# Step 2: Extract individual model fields from JSON
model_vals = self._json_to_model_vals(json_dict)
model_vals['raw_xml'] = xml_content
model_vals['xml_data_json'] = json.dumps(json_dict, ensure_ascii=False)
# Step 3: Create/update profile
profile = self._find_or_create_profile(model_vals, sale_order)
# Step 4: Create application data record
model_vals['profile_id'] = profile.id
model_vals['sale_order_id'] = sale_order.id if sale_order else False
app_data = self.env['fusion.adp.application.data'].create(model_vals)
return profile, app_data
@api.model
def reparse_existing(self, app_data_record):
"""Re-parse an existing application data record from its raw_xml.
Updates all fields in place without creating a new record.
"""
if not app_data_record.raw_xml:
return False
try:
root = ET.fromstring(app_data_record.raw_xml)
except ET.ParseError as e:
_logger.warning('Failed to re-parse XML: %s', e)
return False
form = root.find('Form')
if form is None:
form = root
json_dict = self._xml_to_json(form)
model_vals = self._json_to_model_vals(json_dict)
model_vals['xml_data_json'] = json.dumps(json_dict, ensure_ascii=False)
# Remove fields that shouldn't be overwritten
model_vals.pop('raw_xml', None)
model_vals.pop('profile_id', None)
model_vals.pop('sale_order_id', None)
app_data_record.write(model_vals)
# Also update the linked profile
if app_data_record.profile_id:
profile_vals = {}
if model_vals.get('medical_condition'):
profile_vals['medical_condition'] = model_vals['medical_condition']
if model_vals.get('mobility_status'):
profile_vals['mobility_status'] = model_vals['mobility_status']
if model_vals.get('applicant_first_name'):
profile_vals['first_name'] = model_vals['applicant_first_name']
if model_vals.get('applicant_last_name'):
profile_vals['last_name'] = model_vals['applicant_last_name']
assessment = model_vals.get('assessment_date')
if assessment:
profile_vals['last_assessment_date'] = assessment
if profile_vals:
app_data_record.profile_id.write(profile_vals)
return True
# ------------------------------------------------------------------
# STEP 1: XML -> FLAT JSON DICT (every field preserved)
# ------------------------------------------------------------------
def _xml_to_json(self, form):
"""Convert the entire Form element to a flat JSON dict with dot-notation keys."""
d = OrderedDict()
d['deviceCategory'] = self._t(form, 'deviceCategory')
d['VersionNumber'] = self._t(form, 'VersionNumber')
# Section 1
s1 = form.find('section1')
if s1 is not None:
for tag in ['applicantLastname', 'applicantFirstname', 'applicantMiddleinitial',
'healthNo', 'versionNo', 'DateOfBirth', 'nameLTCH',
'unitNo', 'streetNo', 'streetName', 'rrRoute',
'city', 'province', 'postalCode',
'homePhone', 'busPhone', 'phoneExtension']:
d[f'section1.{tag}'] = self._t(s1, tag)
cob = s1.find('confirmationOfBenefit')
if cob is not None:
for tag in ['q1Yn', 'q1Ifyes', 'q2Yn', 'q3Yn']:
d[f'section1.confirmationOfBenefit.{tag}'] = self._t(cob, tag)
# Section 2
s2 = form.find('section2')
if s2 is not None:
de = s2.find('devicesandEligibility')
if de is not None:
for tag in ['condition', 'status', 'none', 'forearm', 'wheeled', 'manual',
'power', 'addOn', 'scooter', 'seating', 'tiltSystem', 'reclineSystem',
'legRests', 'frame', 'stroller', 'deviceForearm', 'deviceWheeled',
'deviceManual', 'deviceAmbulation', 'deviceDependent', 'deviceDynamic',
'manualDyanmic', 'manualWheelchair', 'powerBase', 'powerScooter',
'ambulation', 'positioning', 'highTech', 'standingFrame',
'adpFunded', 'nonADPFunded']:
d[f'section2.devicesandEligibility.{tag}'] = self._t(de, tag)
# Section 2a
s2a = s2.find('section2a')
if s2a is not None:
for tag in ['walker', 'paediatricFrame', 'forearmCrutches', 'none',
'reason', 'replacementStatus', 'replacementSize', 'replacementADP', 'replacementSpecial',
'confirmation1', 'confirmation2', 'confirmation3', 'confirmation4', 'confirmation5', 'confirmation6',
'seatHeight', 'seatHeightmeasurement', 'handleHeight', 'handleHeightmeasurement',
'handGrips', 'forearm', 'widthHandles', 'widthHandlesmeasurement',
'clientWeight', 'clientWeightmeasurement',
'brakes', 'brakeType', 'noWheels', 'wheelSize', 'backSupport',
'adpWalker', 'adpFrame', 'adpStanding',
'nonADP1', 'nonADP2', 'nonADP3', 'nonADP4', 'nonADP5', 'nonADP6', 'nonADP7', 'nonADP8', 'nonADP9',
'setup1', 'setup2', 'setup3', 'setup4', 'setup5', 'setup6', 'setup7', 'setup8', 'setup9',
'setup10', 'setup11', 'setup12', 'setup13', 'setup14', 'setup15', 'setup16', 'setup17', 'setup18',
'custom', 'costLabour']:
d[f'section2.section2a.{tag}'] = self._t(s2a, tag)
# Section 2b
s2b = s2.find('section2b')
if s2b is not None:
for tag in ['baseDevice', 'powerAddOndevice',
'reason', 'replacementStatus', 'replacementSize', 'replacementADP', 'replacementSpecial',
'confirmation1', 'confirmation2', 'confirmation3', 'confirmation4', 'confirmation5',
'confirmation6', 'confirmation7', 'confirmation8', 'confirmation9', 'confirmation10',
'confirmation11', 'confirmation12', 'confirmation13',
'seatWidth', 'seatWidthmeasurement', 'seatDepth', 'seatDepthmeasurement',
'floorHeight', 'floorHeightmeasurement', 'caneHeight', 'caneHeightmeasurement',
'backHeight', 'backHeightmeasurement', 'restLength', 'restLengthmeasurement',
'clientWeight', 'clientWeightmeasurement',
'adjustableTension', 'heavyDuty', 'recliner', 'footplates', 'legrests',
'spoke', 'projected', 'standardManual', 'gradeAids', 'casterPin',
'amputeeAxle', 'quickRelease', 'stroller', 'oxygen', 'ventilator',
'titanium', 'clothingGuards', 'oneArm', 'uniLateral', 'plastic',
'rationale',
'nonADP1', 'nonADP2', 'nonADP3', 'nonADP4', 'nonADP5', 'nonADP6', 'nonADP7', 'nonADP8', 'nonADP9',
'setup1', 'setup2', 'setup3', 'setup4', 'setup5', 'setup6', 'setup7', 'setup8', 'setup9',
'setup10', 'setup11', 'setup12', 'setup13', 'setup14', 'setup15', 'setup16', 'setup17', 'setup18',
'custom', 'costLabour']:
d[f'section2.section2b.{tag}'] = self._t(s2b, tag)
# Section 2c
s2c = s2.find('section2c')
if s2c is not None:
for tag in ['baseDevice',
'reason', 'replacementStatus', 'replacementSize', 'replacementADP', 'replacementSpecial',
'confirmation1', 'confirmation2', 'confirmation3', 'confirmation4', 'confirmation5',
'seatWidth', 'seatWidthmeasurement', 'backHeight', 'backHeightmeasurement',
'floorHeight', 'floorHeightmeasurement', 'restLength', 'restLengthmeasurement',
'seatDepth', 'seatDepthmeasurement', 'clientWeight', 'clientWeightmeasurement',
'adjustableTension', 'midline', 'manualRecline', 'footplates', 'legrests',
'swingaway', 'onePiece', 'seatPackage1', 'seatPackage2', 'oxygen', 'ventilator',
'spControls1', 'spControls2', 'spControls3', 'spControls4', 'spControls5', 'spControls6',
'autoCorrection', 'rationale',
'powerTilt', 'powerRecline', 'tiltAndRecline', 'powerElevating', 'ControlBox',
'nonADP1', 'nonADP2', 'nonADP3', 'nonADP4', 'nonADP5', 'nonADP6', 'nonADP7', 'nonADP8', 'nonADP9',
'setup1', 'setup2', 'setup3', 'setup4', 'setup5', 'setup6', 'setup7', 'setup8', 'setup9',
'setup10', 'setup11', 'setup12', 'setup13', 'setup14', 'setup15', 'setup16', 'setup17', 'setup18',
'custom', 'costLabour']:
d[f'section2.section2c.{tag}'] = self._t(s2c, tag)
# Section 2d
s2d = s2.find('section2d')
if s2d is not None:
for tag in ['seatM', 'seatCF', 'coverM', 'coverCF', 'optionM', 'optionCF', 'hardwareM', 'hardwareCF',
'adductorM', 'adductorCF', 'pommelCF',
'backM', 'backCF', 'supportoptionM', 'supportoptionCF', 'backcoverCF', 'backHardwareM', 'backHardwareCF',
'completeM', 'completeCF',
'headrestM', 'headrestCF', 'headoptionCF', 'headhardwareM', 'headhardwareCF',
'beltM', 'beltCF', 'beltoptionCF',
'armsupportM', 'armsupportCF', 'armoptionM', 'armoptionCF', 'armhardwareM', 'armhardwareCF',
'trayM', 'trayCF', 'trayoptionM', 'trayoptionCF',
'lateralsupportM', 'lateralsupportCF', 'lateraloptionCF', 'lateralhardwareCF',
'footsupportM', 'footsupportCF', 'footoptionM', 'footoptionCF', 'foothardwareM', 'foothardwareCF',
'reason', 'replacementStatus', 'replacementSize', 'replacementADP', 'replacementSpecial',
'confirmation1', 'confirmation2',
'nonADP1', 'nonADP2', 'nonADP3', 'nonADP4', 'nonADP5', 'nonADP6', 'nonADP7', 'nonADP8', 'nonADP9',
'setup1', 'setup2', 'setup3', 'setup4', 'setup5', 'setup6', 'setup7', 'setup8', 'setup9',
'setup10', 'setup11', 'setup12', 'setup13', 'setup14', 'setup15', 'setup16', 'setup17', 'setup18',
'custom', 'costLabour']:
d[f'section2.section2d.{tag}'] = self._t(s2d, tag)
# Section 3
s3 = form.find('section3')
if s3 is not None:
sig = s3.find('sig')
if sig is not None:
for tag in ['signature', 'person', 'Date']:
d[f'section3.sig.{tag}'] = self._t(sig, tag)
contact = s3.find('contact')
if contact is not None:
for tag in ['relationship', 'applicantLastname', 'applicantFirstname', 'applicantMiddleinitial',
'unitNo', 'streetNo', 'streetName', 'rrRoute',
'city', 'province', 'postalCode', 'homePhone', 'busPhone', 'phoneExtension']:
d[f'section3.contact.{tag}'] = self._t(contact, tag)
# Section 4
s4 = form.find('section4')
if s4 is not None:
auth = s4.find('authorizer')
if auth is not None:
for tag in ['authorizerLastname', 'authorizerFirstname', 'busPhone', 'phoneExtension', 'adpNo', 'signature', 'Date']:
d[f'section4.authorizer.{tag}'] = self._t(auth, tag)
vendor = s4.find('vendor')
if vendor is not None:
for tag in ['vendorBusName', 'adpVendorRegNo', 'vendorLastfirstname', 'positionTitle', 'vendorLocation', 'busPhone', 'phoneExtension', 'signature', 'Date']:
d[f'section4.vendor.{tag}'] = self._t(vendor, tag)
v2 = s4.find('vendor2')
if v2 is not None:
for tag in ['vendorBusName', 'adpVendorRegNo', 'vendorLastfirstname', 'positionTitle', 'vendorLocation', 'busPhone', 'phoneExtension', 'signature', 'Date']:
d[f'section4.vendor2.{tag}'] = self._t(v2, tag)
eq = s4.find('equipmentSpec')
if eq is not None:
d['section4.equipmentSpec.vendorInvoiceNo'] = self._t(eq, 'vendorInvoiceNo')
d['section4.equipmentSpec.vendorADPRegNo'] = self._t(eq, 'vendorADPRegNo')
t2 = eq.find('Table2')
if t2 is not None:
r1 = t2.find('Row1')
if r1 is not None:
for tag in ['Cell1', 'Cell2', 'Cell3', 'Cell4', 'Cell5']:
d[f'section4.equipmentSpec.Table2.Row1.{tag}'] = self._t(r1, tag)
pod = s4.find('proofOfDelivery')
if pod is not None:
for tag in ['signature', 'receivedBy', 'Date']:
d[f'section4.proofOfDelivery.{tag}'] = self._t(pod, tag)
note = s4.find('noteToADP')
if note is not None:
for tag in ['section1', 'section2a', 'section2b', 'section2c', 'section2d',
'section3and4', 'vendorReplacement', 'vendorCustom', 'fundingChart', 'letter']:
d[f'section4.noteToADP.{tag}'] = self._t(note, tag)
return d
# ------------------------------------------------------------------
# STEP 2: JSON DICT -> MODEL FIELD VALUES
# ------------------------------------------------------------------
def _json_to_model_vals(self, d):
"""Map flat JSON dict to fusion.adp.application.data field values."""
g = d.get # shorthand
vals = {}
# Metadata
vals['device_category'] = g('deviceCategory', '') or 'MD'
vals['version_number'] = g('VersionNumber', '')
# Section 1 - Applicant
vals['applicant_last_name'] = g('section1.applicantLastname', '')
vals['applicant_first_name'] = g('section1.applicantFirstname', '')
vals['applicant_middle_initial'] = g('section1.applicantMiddleinitial', '')
vals['health_card_number'] = g('section1.healthNo', '')
vals['health_card_version'] = g('section1.versionNo', '')
vals['date_of_birth'] = self._pd(g('section1.DateOfBirth', ''))
vals['ltch_name'] = g('section1.nameLTCH', '')
vals['unit_number'] = g('section1.unitNo', '')
vals['street_number'] = g('section1.streetNo', '')
vals['street_name'] = g('section1.streetName', '')
vals['rural_route'] = g('section1.rrRoute', '')
vals['city'] = g('section1.city', '')
vals['province'] = g('section1.province', '')
vals['postal_code'] = g('section1.postalCode', '')
vals['home_phone'] = g('section1.homePhone', '')
vals['business_phone'] = g('section1.busPhone', '')
vals['phone_extension'] = g('section1.phoneExtension', '')
# Benefits
q1 = g('section1.confirmationOfBenefit.q1Yn', '').lower()
vals['receives_social_assistance'] = q1 == 'yes'
q1type = g('section1.confirmationOfBenefit.q1Ifyes', '').lower()
vals['benefit_owp'] = 'owp' in q1type if q1type else False
vals['benefit_odsp'] = 'odsp' in q1type if q1type else False
vals['benefit_acsd'] = 'acsd' in q1type if q1type else False
if vals['benefit_owp']:
vals['benefit_type'] = 'owp'
elif vals['benefit_odsp']:
vals['benefit_type'] = 'odsp'
elif vals['benefit_acsd']:
vals['benefit_type'] = 'acsd'
vals['wsib_eligible'] = g('section1.confirmationOfBenefit.q2Yn', '').lower() == 'yes'
vals['vac_eligible'] = g('section1.confirmationOfBenefit.q3Yn', '').lower() == 'yes'
# Section 2 - Devices & Eligibility
vals['medical_condition'] = g('section2.devicesandEligibility.condition', '')
vals['mobility_status'] = g('section2.devicesandEligibility.status', '')
# Previously funded
vals['prev_funded_none'] = bool(g('section2.devicesandEligibility.none', ''))
vals['prev_funded_forearm'] = bool(g('section2.devicesandEligibility.forearm', ''))
vals['prev_funded_wheeled'] = bool(g('section2.devicesandEligibility.wheeled', ''))
vals['prev_funded_manual'] = bool(g('section2.devicesandEligibility.manual', ''))
vals['prev_funded_power'] = bool(g('section2.devicesandEligibility.power', ''))
vals['prev_funded_addon'] = bool(g('section2.devicesandEligibility.addOn', ''))
vals['prev_funded_scooter'] = bool(g('section2.devicesandEligibility.scooter', ''))
vals['prev_funded_seating'] = bool(g('section2.devicesandEligibility.seating', ''))
vals['prev_funded_tilt'] = bool(g('section2.devicesandEligibility.tiltSystem', ''))
vals['prev_funded_recline'] = bool(g('section2.devicesandEligibility.reclineSystem', ''))
vals['prev_funded_legrests'] = bool(g('section2.devicesandEligibility.legRests', ''))
vals['prev_funded_frame'] = bool(g('section2.devicesandEligibility.frame', ''))
vals['prev_funded_stroller'] = bool(g('section2.devicesandEligibility.stroller', ''))
# Devices currently required
vals['device_forearm_crutches'] = bool(g('section2.devicesandEligibility.deviceForearm', ''))
vals['device_wheeled_walker'] = bool(g('section2.devicesandEligibility.deviceWheeled', ''))
vals['device_manual_wheelchair'] = bool(g('section2.devicesandEligibility.deviceManual', ''))
vals['device_ambulation_manual'] = bool(g('section2.devicesandEligibility.deviceAmbulation', ''))
vals['device_dependent_wheelchair'] = bool(g('section2.devicesandEligibility.deviceDependent', ''))
vals['device_dynamic_tilt'] = bool(g('section2.devicesandEligibility.deviceDynamic', ''))
vals['device_manual_dynamic'] = bool(g('section2.devicesandEligibility.manualDyanmic', ''))
vals['device_manual_power_addon'] = bool(g('section2.devicesandEligibility.manualWheelchair', ''))
vals['device_power_base'] = bool(g('section2.devicesandEligibility.powerBase', ''))
vals['device_power_scooter'] = bool(g('section2.devicesandEligibility.powerScooter', ''))
vals['device_ambulation_power'] = bool(g('section2.devicesandEligibility.ambulation', ''))
vals['device_positioning'] = bool(g('section2.devicesandEligibility.positioning', ''))
vals['device_high_tech'] = bool(g('section2.devicesandEligibility.highTech', ''))
vals['device_standing_frame'] = bool(g('section2.devicesandEligibility.standingFrame', ''))
vals['device_adp_funded_mods'] = bool(g('section2.devicesandEligibility.adpFunded', ''))
vals['device_non_adp_funded_mods'] = bool(g('section2.devicesandEligibility.nonADPFunded', ''))
# Section 2a - Walkers
vals['s2a_base_device'] = g('section2.section2a.walker', '')
vals['s2a_paediatric_frame'] = g('section2.section2a.paediatricFrame', '')
vals['s2a_forearm_crutches'] = g('section2.section2a.forearmCrutches', '')
vals['s2a_none'] = g('section2.section2a.none', '')
vals['s2a_reason'] = g('section2.section2a.reason', '')
vals['s2a_replacement_status'] = g('section2.section2a.replacementStatus', '')
vals['s2a_replacement_size'] = g('section2.section2a.replacementSize', '')
vals['s2a_replacement_adp'] = g('section2.section2a.replacementADP', '')
vals['s2a_replacement_special'] = g('section2.section2a.replacementSpecial', '')
for i in range(1, 7):
vals[f's2a_confirm{i}'] = g(f'section2.section2a.confirmation{i}', '')
vals['s2a_seat_height'] = g('section2.section2a.seatHeight', '')
vals['s2a_seat_height_unit'] = g('section2.section2a.seatHeightmeasurement', '')
vals['s2a_handle_height'] = g('section2.section2a.handleHeight', '')
vals['s2a_handle_height_unit'] = g('section2.section2a.handleHeightmeasurement', '')
vals['s2a_hand_grips'] = g('section2.section2a.handGrips', '')
vals['s2a_forearm_attachments'] = g('section2.section2a.forearm', '')
vals['s2a_width_handles'] = g('section2.section2a.widthHandles', '')
vals['s2a_width_handles_unit'] = g('section2.section2a.widthHandlesmeasurement', '')
vals['s2a_client_weight'] = g('section2.section2a.clientWeight', '')
vals['s2a_client_weight_unit'] = g('section2.section2a.clientWeightmeasurement', '')
vals['s2a_brakes'] = g('section2.section2a.brakes', '')
vals['s2a_brake_type'] = g('section2.section2a.brakeType', '')
vals['s2a_num_wheels'] = g('section2.section2a.noWheels', '')
vals['s2a_wheel_size'] = g('section2.section2a.wheelSize', '')
vals['s2a_back_support'] = g('section2.section2a.backSupport', '')
vals['s2a_adp_walker'] = g('section2.section2a.adpWalker', '')
vals['s2a_adp_frame'] = g('section2.section2a.adpFrame', '')
vals['s2a_adp_standing'] = g('section2.section2a.adpStanding', '')
vals['s2a_custom'] = g('section2.section2a.custom', '')
vals['s2a_cost_labour'] = g('section2.section2a.costLabour', '')
# Section 2b - Manual Wheelchairs
vals['s2b_base_device'] = g('section2.section2b.baseDevice', '')
vals['s2b_power_addon'] = g('section2.section2b.powerAddOndevice', '')
vals['s2b_reason'] = g('section2.section2b.reason', '')
vals['s2b_replacement_status'] = g('section2.section2b.replacementStatus', '')
vals['s2b_replacement_size'] = g('section2.section2b.replacementSize', '')
vals['s2b_replacement_adp'] = g('section2.section2b.replacementADP', '')
vals['s2b_replacement_special'] = g('section2.section2b.replacementSpecial', '')
for i in range(1, 14):
vals[f's2b_confirm{i}'] = g(f'section2.section2b.confirmation{i}', '')
vals['s2b_seat_width'] = g('section2.section2b.seatWidth', '')
vals['s2b_seat_width_unit'] = g('section2.section2b.seatWidthmeasurement', '')
vals['s2b_seat_depth'] = g('section2.section2b.seatDepth', '')
vals['s2b_seat_depth_unit'] = g('section2.section2b.seatDepthmeasurement', '')
vals['s2b_floor_height'] = g('section2.section2b.floorHeight', '')
vals['s2b_floor_height_unit'] = g('section2.section2b.floorHeightmeasurement', '')
vals['s2b_cane_height'] = g('section2.section2b.caneHeight', '')
vals['s2b_cane_height_unit'] = g('section2.section2b.caneHeightmeasurement', '')
vals['s2b_back_height'] = g('section2.section2b.backHeight', '')
vals['s2b_back_height_unit'] = g('section2.section2b.backHeightmeasurement', '')
vals['s2b_rest_length'] = g('section2.section2b.restLength', '')
vals['s2b_rest_length_unit'] = g('section2.section2b.restLengthmeasurement', '')
vals['s2b_client_weight'] = g('section2.section2b.clientWeight', '')
vals['s2b_client_weight_unit'] = g('section2.section2b.clientWeightmeasurement', '')
vals['s2b_adjustable_tension'] = bool(g('section2.section2b.adjustableTension', ''))
vals['s2b_heavy_duty'] = bool(g('section2.section2b.heavyDuty', ''))
vals['s2b_recliner'] = bool(g('section2.section2b.recliner', ''))
vals['s2b_footplates'] = bool(g('section2.section2b.footplates', ''))
vals['s2b_legrests'] = bool(g('section2.section2b.legrests', ''))
vals['s2b_spoke'] = bool(g('section2.section2b.spoke', ''))
vals['s2b_projected'] = bool(g('section2.section2b.projected', ''))
vals['s2b_standard_manual'] = bool(g('section2.section2b.standardManual', ''))
vals['s2b_grade_aids'] = bool(g('section2.section2b.gradeAids', ''))
vals['s2b_caster_pin'] = bool(g('section2.section2b.casterPin', ''))
vals['s2b_amputee_axle'] = bool(g('section2.section2b.amputeeAxle', ''))
vals['s2b_quick_release'] = bool(g('section2.section2b.quickRelease', ''))
vals['s2b_stroller'] = bool(g('section2.section2b.stroller', ''))
vals['s2b_oxygen'] = bool(g('section2.section2b.oxygen', ''))
vals['s2b_ventilator'] = bool(g('section2.section2b.ventilator', ''))
vals['s2b_titanium'] = bool(g('section2.section2b.titanium', ''))
vals['s2b_clothing_guards'] = bool(g('section2.section2b.clothingGuards', ''))
vals['s2b_one_arm'] = bool(g('section2.section2b.oneArm', ''))
vals['s2b_uni_lateral'] = bool(g('section2.section2b.uniLateral', ''))
vals['s2b_plastic'] = bool(g('section2.section2b.plastic', ''))
vals['s2b_rationale'] = g('section2.section2b.rationale', '')
vals['s2b_custom'] = g('section2.section2b.custom', '')
vals['s2b_cost_labour'] = g('section2.section2b.costLabour', '')
# Section 2c - Power Bases / Scooters
vals['s2c_base_device'] = g('section2.section2c.baseDevice', '')
vals['s2c_reason'] = g('section2.section2c.reason', '')
vals['s2c_replacement_status'] = g('section2.section2c.replacementStatus', '')
vals['s2c_replacement_size'] = g('section2.section2c.replacementSize', '')
vals['s2c_replacement_adp'] = g('section2.section2c.replacementADP', '')
vals['s2c_replacement_special'] = g('section2.section2c.replacementSpecial', '')
for i in range(1, 6):
vals[f's2c_confirm{i}'] = g(f'section2.section2c.confirmation{i}', '')
vals['s2c_seat_width'] = g('section2.section2c.seatWidth', '')
vals['s2c_seat_width_unit'] = g('section2.section2c.seatWidthmeasurement', '')
vals['s2c_back_height'] = g('section2.section2c.backHeight', '')
vals['s2c_back_height_unit'] = g('section2.section2c.backHeightmeasurement', '')
vals['s2c_floor_height'] = g('section2.section2c.floorHeight', '')
vals['s2c_floor_height_unit'] = g('section2.section2c.floorHeightmeasurement', '')
vals['s2c_rest_length'] = g('section2.section2c.restLength', '')
vals['s2c_rest_length_unit'] = g('section2.section2c.restLengthmeasurement', '')
vals['s2c_seat_depth'] = g('section2.section2c.seatDepth', '')
vals['s2c_seat_depth_unit'] = g('section2.section2c.seatDepthmeasurement', '')
vals['s2c_client_weight'] = g('section2.section2c.clientWeight', '')
vals['s2c_client_weight_unit'] = g('section2.section2c.clientWeightmeasurement', '')
vals['s2c_adjustable_tension'] = bool(g('section2.section2c.adjustableTension', ''))
vals['s2c_midline'] = bool(g('section2.section2c.midline', ''))
vals['s2c_manual_recline'] = bool(g('section2.section2c.manualRecline', ''))
vals['s2c_footplates'] = bool(g('section2.section2c.footplates', ''))
vals['s2c_legrests'] = bool(g('section2.section2c.legrests', ''))
vals['s2c_swingaway'] = bool(g('section2.section2c.swingaway', ''))
vals['s2c_one_piece'] = bool(g('section2.section2c.onePiece', ''))
vals['s2c_seat_package_1'] = bool(g('section2.section2c.seatPackage1', ''))
vals['s2c_seat_package_2'] = bool(g('section2.section2c.seatPackage2', ''))
vals['s2c_oxygen'] = bool(g('section2.section2c.oxygen', ''))
vals['s2c_ventilator'] = bool(g('section2.section2c.ventilator', ''))
vals['s2c_sp_controls_1'] = bool(g('section2.section2c.spControls1', ''))
vals['s2c_sp_controls_2'] = bool(g('section2.section2c.spControls2', ''))
vals['s2c_sp_controls_3'] = bool(g('section2.section2c.spControls3', ''))
vals['s2c_sp_controls_4'] = bool(g('section2.section2c.spControls4', ''))
vals['s2c_sp_controls_5'] = bool(g('section2.section2c.spControls5', ''))
vals['s2c_sp_controls_6'] = bool(g('section2.section2c.spControls6', ''))
vals['s2c_auto_correction'] = bool(g('section2.section2c.autoCorrection', ''))
vals['s2c_rationale'] = g('section2.section2c.rationale', '')
vals['s2c_power_tilt'] = bool(g('section2.section2c.powerTilt', ''))
vals['s2c_power_recline'] = bool(g('section2.section2c.powerRecline', ''))
vals['s2c_tilt_and_recline'] = bool(g('section2.section2c.tiltAndRecline', ''))
vals['s2c_power_elevating'] = bool(g('section2.section2c.powerElevating', ''))
vals['s2c_control_box'] = bool(g('section2.section2c.ControlBox', ''))
vals['s2c_custom'] = g('section2.section2c.custom', '')
vals['s2c_cost_labour'] = g('section2.section2c.costLabour', '')
# Section 2d - Positioning/Seating
vals['s2d_seat_modular'] = bool(g('section2.section2d.seatM', ''))
vals['s2d_seat_custom'] = bool(g('section2.section2d.seatCF', ''))
vals['s2d_seat_cover_modular'] = bool(g('section2.section2d.coverM', ''))
vals['s2d_seat_cover_custom'] = bool(g('section2.section2d.coverCF', ''))
vals['s2d_seat_option_modular'] = bool(g('section2.section2d.optionM', ''))
vals['s2d_seat_option_custom'] = bool(g('section2.section2d.optionCF', ''))
vals['s2d_seat_hardware_modular'] = bool(g('section2.section2d.hardwareM', ''))
vals['s2d_seat_hardware_custom'] = bool(g('section2.section2d.hardwareCF', ''))
vals['s2d_adductor_modular'] = bool(g('section2.section2d.adductorM', ''))
vals['s2d_adductor_custom'] = bool(g('section2.section2d.adductorCF', ''))
vals['s2d_pommel_custom'] = bool(g('section2.section2d.pommelCF', ''))
vals['s2d_back_modular'] = bool(g('section2.section2d.backM', ''))
vals['s2d_back_custom'] = bool(g('section2.section2d.backCF', ''))
vals['s2d_back_option_modular'] = bool(g('section2.section2d.supportoptionM', ''))
vals['s2d_back_option_custom'] = bool(g('section2.section2d.supportoptionCF', ''))
vals['s2d_back_cover_custom'] = bool(g('section2.section2d.backcoverCF', ''))
vals['s2d_back_hardware_modular'] = bool(g('section2.section2d.backHardwareM', ''))
vals['s2d_back_hardware_custom'] = bool(g('section2.section2d.backHardwareCF', ''))
vals['s2d_complete_modular'] = bool(g('section2.section2d.completeM', ''))
vals['s2d_complete_custom'] = bool(g('section2.section2d.completeCF', ''))
vals['s2d_headrest_modular'] = bool(g('section2.section2d.headrestM', ''))
vals['s2d_headrest_custom'] = bool(g('section2.section2d.headrestCF', ''))
vals['s2d_head_option_custom'] = bool(g('section2.section2d.headoptionCF', ''))
vals['s2d_head_hardware_modular'] = bool(g('section2.section2d.headhardwareM', ''))
vals['s2d_head_hardware_custom'] = bool(g('section2.section2d.headhardwareCF', ''))
vals['s2d_belt_modular'] = bool(g('section2.section2d.beltM', ''))
vals['s2d_belt_custom'] = bool(g('section2.section2d.beltCF', ''))
vals['s2d_belt_option_custom'] = bool(g('section2.section2d.beltoptionCF', ''))
vals['s2d_arm_modular'] = bool(g('section2.section2d.armsupportM', ''))
vals['s2d_arm_custom'] = bool(g('section2.section2d.armsupportCF', ''))
vals['s2d_arm_option_modular'] = bool(g('section2.section2d.armoptionM', ''))
vals['s2d_arm_option_custom'] = bool(g('section2.section2d.armoptionCF', ''))
vals['s2d_arm_hardware_modular'] = bool(g('section2.section2d.armhardwareM', ''))
vals['s2d_arm_hardware_custom'] = bool(g('section2.section2d.armhardwareCF', ''))
vals['s2d_tray_modular'] = bool(g('section2.section2d.trayM', ''))
vals['s2d_tray_custom'] = bool(g('section2.section2d.trayCF', ''))
vals['s2d_tray_option_modular'] = bool(g('section2.section2d.trayoptionM', ''))
vals['s2d_tray_option_custom'] = bool(g('section2.section2d.trayoptionCF', ''))
vals['s2d_lateral_modular'] = bool(g('section2.section2d.lateralsupportM', ''))
vals['s2d_lateral_custom'] = bool(g('section2.section2d.lateralsupportCF', ''))
vals['s2d_lateral_option_custom'] = bool(g('section2.section2d.lateraloptionCF', ''))
vals['s2d_lateral_hardware_custom'] = bool(g('section2.section2d.lateralhardwareCF', ''))
vals['s2d_foot_modular'] = bool(g('section2.section2d.footsupportM', ''))
vals['s2d_foot_custom'] = bool(g('section2.section2d.footsupportCF', ''))
vals['s2d_foot_option_modular'] = bool(g('section2.section2d.footoptionM', ''))
vals['s2d_foot_option_custom'] = bool(g('section2.section2d.footoptionCF', ''))
vals['s2d_foot_hardware_modular'] = bool(g('section2.section2d.foothardwareM', ''))
vals['s2d_foot_hardware_custom'] = bool(g('section2.section2d.foothardwareCF', ''))
vals['s2d_reason'] = g('section2.section2d.reason', '')
vals['s2d_replacement_status'] = g('section2.section2d.replacementStatus', '')
vals['s2d_replacement_size'] = g('section2.section2d.replacementSize', '')
vals['s2d_replacement_adp'] = g('section2.section2d.replacementADP', '')
vals['s2d_replacement_special'] = g('section2.section2d.replacementSpecial', '')
vals['s2d_confirm1'] = g('section2.section2d.confirmation1', '')
vals['s2d_confirm2'] = g('section2.section2d.confirmation2', '')
vals['s2d_custom'] = g('section2.section2d.custom', '')
vals['s2d_cost_labour'] = g('section2.section2d.costLabour', '')
# Section 3 - Consent
vals['consent_date'] = self._pd(g('section3.sig.Date', ''))
person = g('section3.sig.person', '').lower()
vals['consent_signed_by'] = 'applicant' if 'applicant' in person else ('agent' if 'agent' in person else False)
vals['agent_relationship'] = g('section3.contact.relationship', '')
vals['agent_last_name'] = g('section3.contact.applicantLastname', '')
vals['agent_first_name'] = g('section3.contact.applicantFirstname', '')
vals['agent_middle_initial'] = g('section3.contact.applicantMiddleinitial', '')
vals['agent_unit'] = g('section3.contact.unitNo', '')
vals['agent_street_no'] = g('section3.contact.streetNo', '')
vals['agent_street_name'] = g('section3.contact.streetName', '')
vals['agent_rural_route'] = g('section3.contact.rrRoute', '')
vals['agent_city'] = g('section3.contact.city', '')
vals['agent_province'] = g('section3.contact.province', '')
vals['agent_postal_code'] = g('section3.contact.postalCode', '')
vals['agent_home_phone'] = g('section3.contact.homePhone', '')
vals['agent_bus_phone'] = g('section3.contact.busPhone', '')
vals['agent_phone_ext'] = g('section3.contact.phoneExtension', '')
# Section 4 - Authorizer
vals['authorizer_last_name'] = g('section4.authorizer.authorizerLastname', '')
vals['authorizer_first_name'] = g('section4.authorizer.authorizerFirstname', '')
vals['authorizer_phone'] = g('section4.authorizer.busPhone', '')
vals['authorizer_phone_ext'] = g('section4.authorizer.phoneExtension', '')
vals['authorizer_adp_number'] = g('section4.authorizer.adpNo', '')
vals['assessment_date'] = self._pd(g('section4.authorizer.Date', ''))
vals['application_date'] = vals['consent_date'] or vals['assessment_date']
# Section 4 - Vendor 1
vals['vendor_business_name'] = g('section4.vendor.vendorBusName', '')
vals['vendor_adp_number'] = g('section4.vendor.adpVendorRegNo', '')
vals['vendor_representative'] = g('section4.vendor.vendorLastfirstname', '')
vals['vendor_position'] = g('section4.vendor.positionTitle', '')
vals['vendor_location'] = g('section4.vendor.vendorLocation', '')
vals['vendor_phone'] = g('section4.vendor.busPhone', '')
vals['vendor_phone_ext'] = g('section4.vendor.phoneExtension', '')
vals['vendor_sign_date'] = self._pd(g('section4.vendor.Date', ''))
# Section 4 - Vendor 2
vals['vendor2_business_name'] = g('section4.vendor2.vendorBusName', '')
vals['vendor2_adp_number'] = g('section4.vendor2.adpVendorRegNo', '')
vals['vendor2_representative'] = g('section4.vendor2.vendorLastfirstname', '')
vals['vendor2_position'] = g('section4.vendor2.positionTitle', '')
vals['vendor2_location'] = g('section4.vendor2.vendorLocation', '')
vals['vendor2_phone'] = g('section4.vendor2.busPhone', '')
vals['vendor2_phone_ext'] = g('section4.vendor2.phoneExtension', '')
vals['vendor2_sign_date'] = self._pd(g('section4.vendor2.Date', ''))
# Equipment Spec
vals['equip_vendor_invoice_no'] = g('section4.equipmentSpec.vendorInvoiceNo', '')
vals['equip_vendor_adp_reg'] = g('section4.equipmentSpec.vendorADPRegNo', '')
vals['equip_cell1'] = g('section4.equipmentSpec.Table2.Row1.Cell1', '')
vals['equip_cell2'] = g('section4.equipmentSpec.Table2.Row1.Cell2', '')
vals['equip_cell3'] = g('section4.equipmentSpec.Table2.Row1.Cell3', '')
vals['equip_cell4'] = g('section4.equipmentSpec.Table2.Row1.Cell4', '')
vals['equip_cell5'] = g('section4.equipmentSpec.Table2.Row1.Cell5', '')
vals['pod_received_by'] = g('section4.proofOfDelivery.receivedBy', '')
vals['pod_date'] = self._pd(g('section4.proofOfDelivery.Date', ''))
# Note to ADP
vals['note_section1'] = bool(g('section4.noteToADP.section1', ''))
vals['note_section2a'] = bool(g('section4.noteToADP.section2a', ''))
vals['note_section2b'] = bool(g('section4.noteToADP.section2b', ''))
vals['note_section2c'] = bool(g('section4.noteToADP.section2c', ''))
vals['note_section2d'] = bool(g('section4.noteToADP.section2d', ''))
vals['note_section3and4'] = bool(g('section4.noteToADP.section3and4', ''))
vals['note_vendor_replacement'] = g('section4.noteToADP.vendorReplacement', '')
vals['note_vendor_custom'] = g('section4.noteToADP.vendorCustom', '')
vals['note_funding_chart'] = g('section4.noteToADP.fundingChart', '')
vals['note_letter'] = g('section4.noteToADP.letter', '')
return vals
# ------------------------------------------------------------------
# PROFILE MANAGEMENT
# ------------------------------------------------------------------
def _find_or_create_profile(self, vals, sale_order=None):
"""Find or create a client profile from parsed application data."""
Profile = self.env['fusion.client.profile']
hc = (vals.get('health_card_number') or '').strip()
first = (vals.get('applicant_first_name') or '').strip()
last = (vals.get('applicant_last_name') or '').strip()
dob = vals.get('date_of_birth')
profile = False
if hc:
profile = Profile.search([('health_card_number', '=', hc)], limit=1)
if not profile and first and last and dob:
profile = Profile.search([
('first_name', '=ilike', first),
('last_name', '=ilike', last),
('date_of_birth', '=', dob),
], limit=1)
profile_vals = {
'first_name': first,
'last_name': last,
'middle_initial': vals.get('applicant_middle_initial', ''),
'health_card_number': hc,
'health_card_version': vals.get('health_card_version', ''),
'date_of_birth': dob,
'ltch_name': vals.get('ltch_name', ''),
'unit_number': vals.get('unit_number', ''),
'street_number': vals.get('street_number', ''),
'street_name': vals.get('street_name', ''),
'rural_route': vals.get('rural_route', ''),
'city': vals.get('city', ''),
'province': vals.get('province', '') or 'ON',
'postal_code': vals.get('postal_code', ''),
'home_phone': vals.get('home_phone', ''),
'business_phone': vals.get('business_phone', ''),
'phone_extension': vals.get('phone_extension', ''),
'medical_condition': vals.get('medical_condition', ''),
'mobility_status': vals.get('mobility_status', ''),
}
if vals.get('receives_social_assistance'):
profile_vals['receives_social_assistance'] = True
profile_vals['benefit_type'] = vals.get('benefit_type')
if vals.get('wsib_eligible'):
profile_vals['wsib_eligible'] = True
if vals.get('vac_eligible'):
profile_vals['vac_eligible'] = True
if vals.get('assessment_date'):
profile_vals['last_assessment_date'] = vals['assessment_date']
# Link to partner
if sale_order and sale_order.partner_id:
profile_vals['partner_id'] = sale_order.partner_id.id
elif not profile or not profile.partner_id:
partner = self._find_partner(first, last)
if partner:
profile_vals['partner_id'] = partner.id
if profile:
profile.write(profile_vals)
else:
profile = Profile.create(profile_vals)
return profile
def _find_partner(self, first_name, last_name):
"""Try to find a matching res.partner."""
if not first_name or not last_name:
return False
Partner = self.env['res.partner']
partner = Partner.search([('name', 'ilike', f'{first_name} {last_name}')], limit=1)
if not partner:
partner = Partner.search([('name', 'ilike', f'{last_name}, {first_name}')], limit=1)
return partner or False
# ------------------------------------------------------------------
# HELPERS
# ------------------------------------------------------------------
@staticmethod
def _t(element, tag):
"""Get text of child element, empty string if missing."""
child = element.find(tag)
if child is not None and child.text:
return child.text.strip()
return ''
@staticmethod
def _pd(date_str):
"""Parse date string, return date or False."""
if not date_str:
return False
for fmt in ('%Y/%m/%d', '%Y-%m-%d', '%Y%m%d'):
try:
return datetime.strptime(date_str.strip(), fmt).date()
except ValueError:
continue
return False