Initial commit
This commit is contained in:
34
fusion_claims/models/__init__.py
Normal file
34
fusion_claims/models/__init__.py
Normal 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
|
||||
1217
fusion_claims/models/account_move.py
Normal file
1217
fusion_claims/models/account_move.py
Normal file
File diff suppressed because it is too large
Load Diff
247
fusion_claims/models/account_move_line.py
Normal file
247
fusion_claims/models/account_move_line.py
Normal 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'
|
||||
21
fusion_claims/models/account_payment.py
Normal file
21
fusion_claims/models/account_payment.py
Normal 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)',
|
||||
)
|
||||
16
fusion_claims/models/account_payment_method_line.py
Normal file
16
fusion_claims/models/account_payment_method_line.py
Normal 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.',
|
||||
)
|
||||
670
fusion_claims/models/adp_application_data.py
Normal file
670
fusion_claims/models/adp_application_data.py
Normal 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)
|
||||
262
fusion_claims/models/adp_posting_schedule.py
Normal file
262
fusion_claims/models/adp_posting_schedule.py
Normal 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
|
||||
164
fusion_claims/models/ai_agent_ext.py
Normal file
164
fusion_claims/models/ai_agent_ext.py
Normal 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,
|
||||
})
|
||||
350
fusion_claims/models/client_chat.py
Normal file
350
fusion_claims/models/client_chat.py
Normal 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,
|
||||
)
|
||||
298
fusion_claims/models/client_profile.py
Normal file
298
fusion_claims/models/client_profile.py
Normal 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'}
|
||||
162
fusion_claims/models/dashboard.py
Normal file
162
fusion_claims/models/dashboard.py
Normal 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, []),
|
||||
}
|
||||
242
fusion_claims/models/email_builder_mixin.py
Normal file
242
fusion_claims/models/email_builder_mixin.py
Normal 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 = ' · '.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')
|
||||
390
fusion_claims/models/fusion_adp_device_code.py
Normal file
390
fusion_claims/models/fusion_adp_device_code.py
Normal 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)]}
|
||||
126
fusion_claims/models/fusion_central_config.py
Normal file
126
fusion_claims/models/fusion_central_config.py
Normal 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)
|
||||
799
fusion_claims/models/fusion_loaner_checkout.py
Normal file
799
fusion_claims/models/fusion_loaner_checkout.py
Normal 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()
|
||||
105
fusion_claims/models/fusion_loaner_history.py
Normal file
105
fusion_claims/models/fusion_loaner_history.py
Normal 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')
|
||||
162
fusion_claims/models/pdf_template_inherit.py
Normal file
162
fusion_claims/models/pdf_template_inherit.py
Normal 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,
|
||||
}
|
||||
185
fusion_claims/models/product_product.py
Normal file
185
fusion_claims/models/product_product.py
Normal 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,
|
||||
}
|
||||
}
|
||||
109
fusion_claims/models/product_template.py
Normal file
109
fusion_claims/models/product_template.py
Normal 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 ''
|
||||
|
||||
73
fusion_claims/models/push_subscription.py
Normal file
73
fusion_claims/models/push_subscription.py
Normal 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,
|
||||
})
|
||||
69
fusion_claims/models/res_company.py
Normal file
69
fusion_claims/models/res_company.py
Normal 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
|
||||
|
||||
602
fusion_claims/models/res_config_settings.py
Normal file
602
fusion_claims/models/res_config_settings.py
Normal 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': {},
|
||||
}
|
||||
|
||||
82
fusion_claims/models/res_partner.py
Normal file
82
fusion_claims/models/res_partner.py
Normal 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'
|
||||
20
fusion_claims/models/res_users.py
Normal file
20
fusion_claims/models/res_users.py
Normal 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',
|
||||
)
|
||||
7886
fusion_claims/models/sale_order.py
Normal file
7886
fusion_claims/models/sale_order.py
Normal file
File diff suppressed because it is too large
Load Diff
362
fusion_claims/models/sale_order_line.py
Normal file
362
fusion_claims/models/sale_order_line.py
Normal 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
|
||||
237
fusion_claims/models/submission_history.py
Normal file
237
fusion_claims/models/submission_history.py
Normal 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
|
||||
116
fusion_claims/models/technician_location.py
Normal file
116
fusion_claims/models/technician_location.py
Normal 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,
|
||||
)
|
||||
2250
fusion_claims/models/technician_task.py
Normal file
2250
fusion_claims/models/technician_task.py
Normal file
File diff suppressed because it is too large
Load Diff
735
fusion_claims/models/xml_parser.py
Normal file
735
fusion_claims/models/xml_parser.py
Normal 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
|
||||
Reference in New Issue
Block a user