850 lines
35 KiB
Python
850 lines
35 KiB
Python
# -*- coding: utf-8 -*-
|
||
|
||
from odoo import api, fields, models, _
|
||
from odoo.exceptions import UserError, ValidationError
|
||
from markupsafe import Markup
|
||
import json
|
||
import uuid
|
||
import logging
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
class WheelchairAssessment(models.Model):
|
||
_name = 'fusion.wc.assessment'
|
||
_description = 'Wheelchair Assessment & Quotation Builder'
|
||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||
_order = 'create_date desc, id desc'
|
||
_rec_name = 'display_name'
|
||
|
||
# =========================================================================
|
||
# STATE & IDENTITY
|
||
# =========================================================================
|
||
state = fields.Selection([
|
||
('draft', 'In Progress'),
|
||
('review', 'Ready for Review'),
|
||
('quoted', 'Quotation Generated'),
|
||
('cancelled', 'Cancelled'),
|
||
], string='Status', default='draft', tracking=True, index=True)
|
||
|
||
display_name = fields.Char(
|
||
string='Display Name',
|
||
compute='_compute_display_name',
|
||
store=True,
|
||
)
|
||
reference = fields.Char(
|
||
string='Reference',
|
||
readonly=True,
|
||
copy=False,
|
||
default=lambda self: _('New'),
|
||
)
|
||
|
||
# =========================================================================
|
||
# STEP 1: CLIENT & EQUIPMENT
|
||
# =========================================================================
|
||
partner_id = fields.Many2one('res.partner', string='Client',
|
||
tracking=True, index=True)
|
||
create_new_partner = fields.Boolean(string='New Client', default=False)
|
||
|
||
# Client info (for new clients or override)
|
||
client_first_name = fields.Char(string='First Name')
|
||
client_last_name = fields.Char(string='Last Name')
|
||
client_name = fields.Char(string='Client Name',
|
||
compute='_compute_client_name', store=True)
|
||
client_phone = fields.Char(string='Phone')
|
||
client_email = fields.Char(string='Email')
|
||
client_street = fields.Char(string='Street')
|
||
client_street2 = fields.Char(string='Unit/Apt')
|
||
client_city = fields.Char(string='City')
|
||
client_state_id = fields.Many2one('res.country.state', string='Province')
|
||
client_zip = fields.Char(string='Postal Code')
|
||
client_country_id = fields.Many2one('res.country', string='Country')
|
||
client_dob = fields.Date(string='Date of Birth')
|
||
client_health_card = fields.Char(string='Health Card Number')
|
||
|
||
equipment_type = fields.Selection(
|
||
selection='_get_equipment_type_selection',
|
||
string='Equipment Type', required=True, tracking=True, index=True)
|
||
|
||
@api.model
|
||
def _get_equipment_type_selection(self):
|
||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||
if types:
|
||
return [(t.code, t.name) for t in types]
|
||
return [
|
||
('manual_wheelchair', 'Manual Wheelchair'),
|
||
('power_wheelchair', 'Power Wheelchair / Scooter'),
|
||
('walker', 'Walker / Rollator / Ambulation Aid'),
|
||
]
|
||
|
||
wheelchair_type = fields.Selection([
|
||
('type_1', 'Type 1 - Standard'),
|
||
('type_2', 'Type 2 - Lightweight'),
|
||
('type_3', 'Type 3 - Ultra Lightweight'),
|
||
('type_4', 'Type 4 - Rigid Frame'),
|
||
('type_5', 'Type 5 - Dynamic Tilt'),
|
||
], string='Wheelchair Category')
|
||
|
||
powerchair_type = fields.Selection([
|
||
('type_1', 'Power Base Type 1'),
|
||
('type_2', 'Power Base Type 2'),
|
||
('type_3', 'Power Base Type 3'),
|
||
('scooter', 'Power Scooter'),
|
||
], string='Power Device Category')
|
||
|
||
walker_type = fields.Selection([
|
||
('adult_walker_1', 'Adult Wheeled Walker Type 1'),
|
||
('adult_walker_2', 'Adult Wheeled Walker Type 2'),
|
||
('adult_walker_3', 'Adult Wheeled Walker Type 3'),
|
||
('paed_walker_1', 'Paediatric Specific Wheeled Walker Type 1'),
|
||
('paed_walker_2', 'Paediatric Specific Wheeled Walker Type 2'),
|
||
('paed_walking_frame', 'Paediatric Specific Walking Frame'),
|
||
('paed_standing_1', 'Paediatric Standing Frame Type 1'),
|
||
('paed_standing_2', 'Paediatric Standing Frame Type 2'),
|
||
('forearm_crutches', 'Forearm Crutches'),
|
||
('stroller', 'Paediatric Specific Specialty Stroller'),
|
||
], string='Walker / Aid Category')
|
||
|
||
client_type = fields.Selection([
|
||
('reg', 'REG - Regular ADP (75/25)'),
|
||
('ods', 'ODS - ODSP (100% ADP)'),
|
||
('acs', 'ACS - ACSD (100% ADP)'),
|
||
('owp', 'OWP - Ontario Works (100% ADP)'),
|
||
('ltc', 'LTC - Long Term Care'),
|
||
('sen', 'SEN - Senior'),
|
||
('cca', 'CCA - Community Care'),
|
||
], string='Client Type', default='reg')
|
||
|
||
build_type = fields.Selection([
|
||
('modular', 'Modular'),
|
||
('custom_fabricated', 'Custom Fabricated'),
|
||
], string='Build Type', default='modular')
|
||
|
||
reason_for_application = fields.Selection([
|
||
('first_access', 'First Access'),
|
||
('additions', 'Additions'),
|
||
('modifications', 'Modifications'),
|
||
('replacements', 'Replacements'),
|
||
], string='Reason for Application')
|
||
|
||
# Assessment context
|
||
sales_rep_id = fields.Many2one('res.users', string='Sales Rep',
|
||
default=lambda self: self.env.user, tracking=True, index=True)
|
||
authorizer_id = fields.Many2one('res.partner', string='Authorizer/OT',
|
||
tracking=True)
|
||
assessment_date = fields.Datetime(string='Assessment Date',
|
||
default=fields.Datetime.now)
|
||
|
||
# =========================================================================
|
||
# STEP 2: ADP PRESCRIPTION MEASUREMENTS
|
||
# =========================================================================
|
||
seat_width = fields.Float(string='Seat Width', digits=(10, 2))
|
||
seat_width_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Seat Width Unit', default='inches')
|
||
|
||
seat_depth = fields.Float(string='Seat Depth', digits=(10, 2))
|
||
seat_depth_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Seat Depth Unit', default='inches')
|
||
|
||
finished_seat_to_floor_height = fields.Float(
|
||
string='Finished Seat to Floor Height', digits=(10, 2))
|
||
seat_to_floor_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Seat to Floor Unit', default='inches')
|
||
|
||
back_cane_height = fields.Float(string='Back Cane Height', digits=(10, 2))
|
||
cane_height_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Cane Height Unit', default='inches')
|
||
|
||
finished_back_height = fields.Float(
|
||
string='Finished Back Height', digits=(10, 2))
|
||
back_height_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Back Height Unit', default='inches')
|
||
|
||
finished_leg_rest_length = fields.Float(
|
||
string='Finished Leg Rest Length', digits=(10, 2))
|
||
leg_rest_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Leg Rest Unit', default='inches')
|
||
|
||
client_weight = fields.Float(string='Client Weight', digits=(10, 1))
|
||
client_weight_unit = fields.Selection([
|
||
('lbs', 'lbs'), ('kg', 'kg')
|
||
], string='Weight Unit', default='lbs')
|
||
|
||
# =========================================================================
|
||
# WALKER / AMBULATION AID PRESCRIPTION (Section 2a)
|
||
# =========================================================================
|
||
walker_seat_height = fields.Float(string='Seat Height', digits=(10, 2))
|
||
walker_seat_height_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm'), ('na', 'N/A')
|
||
], string='Seat Height Unit', default='inches')
|
||
|
||
push_handle_height = fields.Float(string='Push Handle Height', digits=(10, 2))
|
||
push_handle_height_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Push Handle Height Unit', default='inches')
|
||
|
||
hand_grips = fields.Selection([
|
||
('none', 'None'), ('standard', 'Standard'), ('anatomical', 'Anatomical'),
|
||
], string='Hand Grips')
|
||
forearm_attachments = fields.Selection([
|
||
('one', 'One'), ('two', 'Two'),
|
||
], string='Forearm Attachments')
|
||
|
||
width_between_push_handles = fields.Float(
|
||
string='Width Between Push Handles', digits=(10, 2))
|
||
push_handle_width_unit = fields.Selection([
|
||
('inches', 'Inches'), ('cm', 'cm')
|
||
], string='Push Handle Width Unit', default='inches')
|
||
|
||
walker_brakes = fields.Selection([
|
||
('none', 'None'), ('push_to_lock', 'Push-To-Lock'),
|
||
('auto_stop', 'Auto Stop'),
|
||
], string='Brakes')
|
||
walker_brake_type = fields.Selection([
|
||
('none', 'None'), ('bilateral', 'Bilateral'),
|
||
('one_hand', 'One Hand'),
|
||
], string='Brake Type')
|
||
walker_num_wheels = fields.Selection([
|
||
('two', 'Two'), ('three', 'Three'), ('four', 'Four'),
|
||
], string='Number of Wheels')
|
||
walker_wheel_size = fields.Selection([
|
||
('4_6', '4-6 inches'), ('6_8', '6-8 inches'), ('8_10', '8-10 inches'),
|
||
], string='Wheel Size')
|
||
walker_back_support = fields.Selection([
|
||
('yes', 'Yes'), ('no', 'No'),
|
||
], string='Back Support')
|
||
|
||
# Walker ADP options (checkboxes)
|
||
walker_adolescent_wheeled_walker = fields.Boolean(
|
||
string='Adolescent Size Paediatric Specific Wheeled Walker')
|
||
walker_adolescent_walking_frame = fields.Boolean(
|
||
string='Adolescent Size Paediatric Wheeled Walker – Walking Frame')
|
||
walker_adolescent_standing_frame = fields.Boolean(
|
||
string='Adolescent Size Paediatric Standing Frame')
|
||
|
||
# =========================================================================
|
||
# POWER BASE / SCOOTER PRESCRIPTION (Section 2c)
|
||
# =========================================================================
|
||
# Note: power bases share seat_width, finished_back_height,
|
||
# finished_seat_to_floor_height, finished_leg_rest_length, seat_depth,
|
||
# and client_weight from the main measurement fields above.
|
||
# Scooters only require client_weight (field 6).
|
||
|
||
# Power ADP options (checkboxes from screenshot)
|
||
pw_adjustable_tension_back = fields.Boolean(
|
||
string='Adjustable Tension Back Upholstery')
|
||
pw_midline_control = fields.Boolean(string='Midline Control')
|
||
pw_manual_recline = fields.Boolean(string='Manual Recline Option')
|
||
pw_angle_adjustable_footplates = fields.Boolean(
|
||
string='Angle Adjustable Footplates (pair)')
|
||
pw_manual_elevating_legrests = fields.Boolean(
|
||
string='Manual Elevating Legrests (pair)')
|
||
pw_swingaway_bracket = fields.Boolean(string='Swingaway Mounting Bracket')
|
||
pw_front_riggings = fields.Boolean(string='One Piece 90/90 Front Riggings')
|
||
pw_seat_package_1 = fields.Boolean(
|
||
string='Seat Package 1 for Power Bases',
|
||
help='Includes frame, sling upholstery, armrests, footrests')
|
||
pw_seat_package_2 = fields.Boolean(
|
||
string='Seat Package 2 for Power Bases',
|
||
help='Includes deluxe seat and back, armrests, footrests')
|
||
pw_oxygen_tank = fields.Boolean(string='Oxygen Tank Holder')
|
||
pw_ventilator_tray = fields.Boolean(string='Ventilator Tray')
|
||
|
||
# Specialty Components (* require clinical rationale)
|
||
pw_specialty_1_joystick = fields.Boolean(
|
||
string='Specialty Controls 1 Non Standard Joystick*')
|
||
pw_specialty_2_chin = fields.Boolean(
|
||
string='Specialty Controls 2 Chin/Rim Control*')
|
||
pw_specialty_3_touch = fields.Boolean(
|
||
string='Specialty Controls 3 Simple Touch*')
|
||
pw_specialty_4_proximity = fields.Boolean(
|
||
string='Specialty Controls 4 Proximity Control*')
|
||
pw_specialty_5_breath = fields.Boolean(
|
||
string='Specialty Controls 5 Breath Control*')
|
||
pw_specialty_6_scanners = fields.Boolean(
|
||
string='Specialty Controls 6 Scanners*')
|
||
pw_auto_correction = fields.Boolean(string='Auto Correction System*')
|
||
pw_specialty_rationale = fields.Text(
|
||
string='Clinical Rationale for Specialty Components')
|
||
|
||
# Power Positioning Devices (require Justification for Funding Chart)
|
||
pw_power_tilt_only = fields.Boolean(string='Power Tilt Only')
|
||
pw_power_recline_only = fields.Boolean(string='Power Recline Only')
|
||
pw_power_tilt_recline = fields.Boolean(string='Power Tilt and Recline')
|
||
pw_power_elevating_footrests = fields.Boolean(
|
||
string='Power Elevating Footrests')
|
||
pw_multi_function_control = fields.Boolean(
|
||
string='Multi-Function Control Box')
|
||
pw_power_add_on = fields.Boolean(string='Power Add-On Device')
|
||
|
||
# Computed: convert to inches/lbs for rule evaluation
|
||
seat_width_inches = fields.Float(
|
||
compute='_compute_normalized_measurements', store=True)
|
||
seat_depth_inches = fields.Float(
|
||
compute='_compute_normalized_measurements', store=True)
|
||
back_height_inches = fields.Float(
|
||
compute='_compute_normalized_measurements', store=True)
|
||
client_weight_lbs = fields.Float(
|
||
compute='_compute_normalized_measurements', store=True)
|
||
|
||
# =========================================================================
|
||
# STEP 3: FRAME
|
||
# =========================================================================
|
||
frame_product_tmpl_id = fields.Many2one('product.template',
|
||
string='Frame Template')
|
||
frame_product_id = fields.Many2one('product.product',
|
||
string='Wheelchair Frame')
|
||
frame_notes = fields.Text(string='Frame Notes')
|
||
|
||
# =========================================================================
|
||
# STEP 4 & 5: SEATING, OPTIONS, ACCESSORIES (via lines)
|
||
# =========================================================================
|
||
line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
|
||
string='Selected Items')
|
||
upcharge_line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
|
||
domain=[('is_upcharge', '=', True)], string='Auto-Applied Upcharges')
|
||
|
||
# =========================================================================
|
||
# STEP 6: REVIEW & RESULT
|
||
# =========================================================================
|
||
sale_order_id = fields.Many2one('sale.order',
|
||
string='Generated Quotation', readonly=True, copy=False)
|
||
total_estimate = fields.Float(string='Total Estimate',
|
||
compute='_compute_totals', store=True)
|
||
total_adp_estimate = fields.Float(string='Estimated ADP Portion',
|
||
compute='_compute_totals', store=True)
|
||
total_client_estimate = fields.Float(string='Estimated Client Portion',
|
||
compute='_compute_totals', store=True)
|
||
|
||
notes = fields.Text(string='Additional Notes')
|
||
|
||
# Form state for save/resume
|
||
current_step = fields.Integer(string='Current Step', default=1)
|
||
form_data_json = fields.Text(string='Form State (JSON)',
|
||
help='Stores intermediate form state for save/resume')
|
||
|
||
# Generic equipment data (for non-wheelchair equipment types)
|
||
equipment_data_json = fields.Text(string='Equipment Data (JSON)',
|
||
help='JSON storage for equipment-specific field values. '
|
||
'Used by dynamic measurement/custom steps for non-wheelchair types.')
|
||
|
||
# =========================================================================
|
||
# SHARING & PUBLIC ACCESS
|
||
# =========================================================================
|
||
access_token = fields.Char(
|
||
string='Access Token', copy=False, index=True,
|
||
help='Token for public access without login')
|
||
portal_url = fields.Char(
|
||
string='Portal URL', compute='_compute_portal_url')
|
||
public_url = fields.Char(
|
||
string='Public URL', compute='_compute_public_url')
|
||
|
||
# =========================================================================
|
||
# COMPUTED FIELDS
|
||
# =========================================================================
|
||
@api.depends('client_first_name', 'client_last_name', 'partner_id')
|
||
def _compute_client_name(self):
|
||
for rec in self:
|
||
if rec.partner_id:
|
||
rec.client_name = rec.partner_id.name
|
||
elif rec.client_first_name or rec.client_last_name:
|
||
parts = [rec.client_first_name or '', rec.client_last_name or '']
|
||
rec.client_name = ' '.join(p for p in parts if p)
|
||
else:
|
||
rec.client_name = ''
|
||
|
||
@api.depends('reference', 'client_name')
|
||
def _compute_display_name(self):
|
||
for rec in self:
|
||
parts = [rec.reference or _('New')]
|
||
if rec.client_name:
|
||
parts.append(rec.client_name)
|
||
rec.display_name = ' - '.join(parts)
|
||
|
||
@api.depends(
|
||
'seat_width', 'seat_width_unit',
|
||
'seat_depth', 'seat_depth_unit',
|
||
'finished_back_height', 'back_height_unit',
|
||
'client_weight', 'client_weight_unit',
|
||
)
|
||
def _compute_normalized_measurements(self):
|
||
"""Convert all measurements to inches/lbs for consistent rule evaluation."""
|
||
for rec in self:
|
||
rec.seat_width_inches = (
|
||
rec.seat_width if rec.seat_width_unit == 'inches'
|
||
else rec.seat_width / 2.54
|
||
)
|
||
rec.seat_depth_inches = (
|
||
rec.seat_depth if rec.seat_depth_unit == 'inches'
|
||
else rec.seat_depth / 2.54
|
||
)
|
||
rec.back_height_inches = (
|
||
rec.finished_back_height if rec.back_height_unit == 'inches'
|
||
else rec.finished_back_height / 2.54
|
||
)
|
||
rec.client_weight_lbs = (
|
||
rec.client_weight if rec.client_weight_unit == 'lbs'
|
||
else rec.client_weight * 2.20462
|
||
)
|
||
|
||
@api.depends('line_ids.subtotal', 'line_ids.adp_portion', 'line_ids.client_portion')
|
||
def _compute_totals(self):
|
||
for rec in self:
|
||
rec.total_estimate = sum(rec.line_ids.mapped('subtotal'))
|
||
rec.total_adp_estimate = sum(rec.line_ids.mapped('adp_portion'))
|
||
rec.total_client_estimate = sum(rec.line_ids.mapped('client_portion'))
|
||
|
||
def _compute_portal_url(self):
|
||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||
for rec in self:
|
||
rec.portal_url = f'{base}/my/quotation/builder/{rec.id}/edit' if rec.id else False
|
||
|
||
def _compute_public_url(self):
|
||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||
for rec in self:
|
||
if rec.access_token:
|
||
rec.public_url = f'{base}/quotation/form/{rec.access_token}'
|
||
else:
|
||
rec.public_url = False
|
||
|
||
# =========================================================================
|
||
# SHARING ACTIONS
|
||
# =========================================================================
|
||
def _generate_access_token(self):
|
||
"""Generate a unique access token for public sharing."""
|
||
for rec in self:
|
||
if not rec.access_token:
|
||
rec.access_token = uuid.uuid4().hex
|
||
|
||
def action_open_portal_form(self):
|
||
"""Open the portal assessment form in a new browser tab."""
|
||
self.ensure_one()
|
||
return {
|
||
'type': 'ir.actions.act_url',
|
||
'url': self.portal_url,
|
||
'target': 'new',
|
||
}
|
||
|
||
def action_generate_share_link(self):
|
||
"""Generate a public access token and show the shareable URL."""
|
||
self.ensure_one()
|
||
self._generate_access_token()
|
||
return {
|
||
'type': 'ir.actions.client',
|
||
'tag': 'display_notification',
|
||
'params': {
|
||
'title': _('Share Link Generated'),
|
||
'message': _('Public URL copied to clipboard — '
|
||
'also visible in the Sharing section of this form.'),
|
||
'type': 'info',
|
||
'sticky': False,
|
||
},
|
||
}
|
||
|
||
# =========================================================================
|
||
# CRUD OVERRIDES
|
||
# =========================================================================
|
||
@api.model_create_multi
|
||
def create(self, vals_list):
|
||
for vals in vals_list:
|
||
if vals.get('reference', _('New')) == _('New'):
|
||
vals['reference'] = self.env['ir.sequence'].next_by_code(
|
||
'fusion.wc.assessment') or _('New')
|
||
return super().create(vals_list)
|
||
|
||
# =========================================================================
|
||
# UPCHARGE RULE ENGINE
|
||
# =========================================================================
|
||
def _evaluate_upcharge_rules(self):
|
||
"""Evaluate all active upcharge rules against current assessment data.
|
||
Creates assessment lines for triggered rules.
|
||
Returns recordset of triggered rules.
|
||
"""
|
||
self.ensure_one()
|
||
|
||
# Remove previously auto-applied upcharges (allows re-evaluation)
|
||
self.line_ids.filtered(lambda l: l.is_upcharge).unlink()
|
||
|
||
rules = self.env['fusion.wc.upcharge.rule'].search([
|
||
('active', '=', True),
|
||
('equipment_type', 'in', [self.equipment_type, 'both']),
|
||
])
|
||
|
||
triggered = self.env['fusion.wc.upcharge.rule']
|
||
# Track exclusive groups — only the first matching rule per group applies
|
||
exclusive_triggered = {}
|
||
|
||
for rule in rules.sorted('sequence'):
|
||
# Check mutual exclusion
|
||
if rule.mutually_exclusive_group:
|
||
if rule.mutually_exclusive_group in exclusive_triggered:
|
||
continue
|
||
|
||
matched = False
|
||
|
||
if rule.trigger_type == 'measurement':
|
||
value = self._get_measurement_value(rule.measurement_field)
|
||
if value and self._compare_values(value, rule.comparison, rule.threshold_value):
|
||
matched = True
|
||
|
||
elif rule.trigger_type == 'weight':
|
||
weight = self.client_weight_lbs
|
||
if weight > rule.weight_min:
|
||
if not rule.weight_max or weight <= rule.weight_max:
|
||
matched = True
|
||
|
||
elif rule.trigger_type == 'dimension_mismatch':
|
||
val1 = self._get_measurement_value(rule.compare_field_1)
|
||
val2 = self._get_measurement_value(rule.compare_field_2)
|
||
if val1 and val2 and abs(val1 - val2) > 0.01:
|
||
matched = True
|
||
|
||
if matched:
|
||
self._create_upcharge_line(rule)
|
||
triggered |= rule
|
||
if rule.mutually_exclusive_group:
|
||
exclusive_triggered[rule.mutually_exclusive_group] = rule
|
||
|
||
return triggered
|
||
|
||
def _get_measurement_value(self, field_name):
|
||
"""Map measurement field selection to actual normalized (inches) value."""
|
||
field_map = {
|
||
'seat_width': self.seat_width_inches,
|
||
'seat_depth': self.seat_depth_inches,
|
||
'back_width': self.seat_width_inches, # backrest width defaults to seat width
|
||
'back_height': self.back_height_inches,
|
||
'seat_to_floor': (
|
||
self.finished_seat_to_floor_height
|
||
if self.seat_to_floor_unit == 'inches'
|
||
else self.finished_seat_to_floor_height / 2.54
|
||
),
|
||
'leg_rest_length': (
|
||
self.finished_leg_rest_length
|
||
if self.leg_rest_unit == 'inches'
|
||
else self.finished_leg_rest_length / 2.54
|
||
),
|
||
}
|
||
return field_map.get(field_name, 0.0)
|
||
|
||
@staticmethod
|
||
def _compare_values(value, comparison, threshold):
|
||
"""Evaluate a comparison between value and threshold."""
|
||
if comparison == 'gt':
|
||
return value > threshold
|
||
elif comparison == 'gte':
|
||
return value >= threshold
|
||
elif comparison == 'lt':
|
||
return value < threshold
|
||
elif comparison == 'eq':
|
||
return abs(value - threshold) < 0.01
|
||
elif comparison == 'neq':
|
||
return abs(value - threshold) >= 0.01
|
||
return False
|
||
|
||
def _create_upcharge_line(self, rule):
|
||
"""Create an assessment line from an upcharge rule."""
|
||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||
adp_device = ADPDevice.search(
|
||
[('device_code', '=', rule.adp_device_code), ('active', '=', True)],
|
||
limit=1
|
||
)
|
||
|
||
vals = {
|
||
'assessment_id': self.id,
|
||
'product_id': rule.product_id.id if rule.product_id else False,
|
||
'product_name': (
|
||
adp_device.device_description
|
||
if adp_device else rule.name
|
||
),
|
||
'quantity': 1,
|
||
'adp_device_code': rule.adp_device_code,
|
||
'adp_price': adp_device.adp_price if adp_device else 0,
|
||
'unit_price': adp_device.adp_price if adp_device else 0,
|
||
'is_upcharge': True,
|
||
'upcharge_rule_id': rule.id,
|
||
'upcharge_reason': rule.description or rule.name,
|
||
}
|
||
return self.env['fusion.wc.assessment.line'].create(vals)
|
||
|
||
# =========================================================================
|
||
# QUOTATION GENERATION
|
||
# =========================================================================
|
||
def action_generate_quotation(self):
|
||
"""Generate a draft sale.order from this assessment."""
|
||
self.ensure_one()
|
||
if self.sale_order_id:
|
||
raise UserError(_('A quotation has already been generated for this assessment.'))
|
||
|
||
# Run upcharge engine first
|
||
triggered_rules = self._evaluate_upcharge_rules()
|
||
|
||
# Resolve or create partner
|
||
partner = self._resolve_partner()
|
||
|
||
# Add frame as an assessment line if not already present
|
||
self._ensure_frame_line()
|
||
|
||
# Create sale order
|
||
so_vals = self._prepare_sale_order_vals(partner)
|
||
sale_order = self.env['sale.order'].sudo().create(so_vals)
|
||
|
||
# Create sale order lines from assessment lines
|
||
for line in self.line_ids:
|
||
sol_vals = self._prepare_sale_order_line_vals(sale_order, line)
|
||
self.env['sale.order.line'].sudo().create(sol_vals)
|
||
|
||
# Update assessment
|
||
self.write({
|
||
'sale_order_id': sale_order.id,
|
||
'state': 'quoted',
|
||
})
|
||
|
||
# Post chatter message
|
||
self._post_quotation_message(sale_order, triggered_rules)
|
||
|
||
return {
|
||
'type': 'ir.actions.act_window',
|
||
'res_model': 'sale.order',
|
||
'res_id': sale_order.id,
|
||
'view_mode': 'form',
|
||
'target': 'current',
|
||
}
|
||
|
||
def _resolve_partner(self):
|
||
"""Get existing partner or create a new one."""
|
||
self.ensure_one()
|
||
if self.partner_id:
|
||
return self.partner_id
|
||
|
||
if not self.client_first_name and not self.client_last_name:
|
||
raise UserError(_('Please select an existing client or provide client name.'))
|
||
|
||
partner_vals = {
|
||
'name': self.client_name,
|
||
'phone': self.client_phone,
|
||
'email': self.client_email,
|
||
'street': self.client_street,
|
||
'street2': self.client_street2,
|
||
'city': self.client_city,
|
||
'state_id': self.client_state_id.id if self.client_state_id else False,
|
||
'zip': self.client_zip,
|
||
'country_id': self.client_country_id.id if self.client_country_id else False,
|
||
'customer_rank': 1,
|
||
}
|
||
partner = self.env['res.partner'].sudo().create(partner_vals)
|
||
self.partner_id = partner
|
||
return partner
|
||
|
||
def _ensure_frame_line(self):
|
||
"""Add the frame product as an assessment line if not already present."""
|
||
self.ensure_one()
|
||
|
||
# If we have a template but no resolved variant, resolve to first variant
|
||
if not self.frame_product_id and self.frame_product_tmpl_id:
|
||
variant = self.frame_product_tmpl_id.product_variant_ids[:1]
|
||
if variant:
|
||
self.frame_product_id = variant.id
|
||
|
||
if not self.frame_product_id:
|
||
return
|
||
|
||
# Pick the right frame section based on equipment type
|
||
frame_code_map = {
|
||
'manual_wheelchair': 'mw_frame',
|
||
'power_wheelchair': 'pw_frame',
|
||
'walker': 'walker_frame',
|
||
}
|
||
code = frame_code_map.get(self.equipment_type, 'mw_frame')
|
||
frame_section = self.env['fusion.wc.section'].search(
|
||
[('code', '=', code)], limit=1)
|
||
if not frame_section:
|
||
# Fall back to legacy code
|
||
frame_section = self.env['fusion.wc.section'].search(
|
||
[('code', '=', 'frame')], limit=1)
|
||
|
||
existing = self.line_ids.filtered(
|
||
lambda l: l.product_id == self.frame_product_id and not l.is_upcharge)
|
||
if existing:
|
||
return
|
||
|
||
# Build description from measurements
|
||
desc_parts = [self.frame_product_id.display_name]
|
||
if self.seat_width:
|
||
desc_parts.append(f"Seat Width: {self.seat_width}{self.seat_width_unit}")
|
||
if self.seat_depth:
|
||
desc_parts.append(f"Seat Depth: {self.seat_depth}{self.seat_depth_unit}")
|
||
if self.finished_seat_to_floor_height:
|
||
desc_parts.append(
|
||
f"Seat to Floor: {self.finished_seat_to_floor_height}"
|
||
f"{self.seat_to_floor_unit}")
|
||
if self.finished_leg_rest_length:
|
||
desc_parts.append(
|
||
f"Leg Rest: {self.finished_leg_rest_length}{self.leg_rest_unit}")
|
||
if self.back_cane_height:
|
||
desc_parts.append(
|
||
f"Cane Height: {self.back_cane_height}{self.cane_height_unit}")
|
||
|
||
product = self.frame_product_id
|
||
adp_code = product.product_tmpl_id.x_fc_adp_device_code or ''
|
||
adp_price = product.product_tmpl_id.x_fc_adp_price or product.lst_price
|
||
|
||
self.env['fusion.wc.assessment.line'].create({
|
||
'assessment_id': self.id,
|
||
'section_id': frame_section.id if frame_section else False,
|
||
'product_id': product.id,
|
||
'product_name': '\n'.join(desc_parts),
|
||
'quantity': 1,
|
||
'adp_device_code': adp_code,
|
||
'adp_price': adp_price,
|
||
'unit_price': product.lst_price,
|
||
})
|
||
|
||
def _prepare_sale_order_vals(self, partner):
|
||
"""Prepare values for sale.order creation."""
|
||
self.ensure_one()
|
||
|
||
# Map client_type to fusion_claims x_fc_client_type
|
||
client_type_map = {
|
||
'reg': 'REG',
|
||
'ods': 'ODS',
|
||
'acs': 'ACS',
|
||
'owp': 'OWP',
|
||
'ltc': 'LTC',
|
||
'sen': 'SEN',
|
||
'cca': 'CCA',
|
||
}
|
||
|
||
vals = {
|
||
'partner_id': partner.id,
|
||
'user_id': self.sales_rep_id.id,
|
||
'state': 'draft',
|
||
'origin': f'WC Assessment: {self.reference}',
|
||
'wc_assessment_id': self.id,
|
||
}
|
||
|
||
# Set fusion_claims fields if available
|
||
if hasattr(self.env['sale.order'], 'x_fc_sale_type'):
|
||
vals['x_fc_sale_type'] = 'adp'
|
||
if hasattr(self.env['sale.order'], 'x_fc_client_type'):
|
||
vals['x_fc_client_type'] = client_type_map.get(self.client_type, 'REG')
|
||
if hasattr(self.env['sale.order'], 'x_fc_adp_application_status'):
|
||
vals['x_fc_adp_application_status'] = 'quotation'
|
||
if self.authorizer_id and hasattr(self.env['sale.order'], 'x_fc_authorizer_id'):
|
||
vals['x_fc_authorizer_id'] = self.authorizer_id.id
|
||
|
||
return vals
|
||
|
||
def _prepare_sale_order_line_vals(self, sale_order, line):
|
||
"""Prepare values for sale.order.line creation from assessment line."""
|
||
vals = {
|
||
'order_id': sale_order.id,
|
||
'product_id': line.product_id.id if line.product_id else False,
|
||
'name': line.product_name or (
|
||
line.product_id.display_name if line.product_id else line.adp_device_code
|
||
),
|
||
'product_uom_qty': line.quantity,
|
||
'price_unit': line.unit_price or (
|
||
line.product_id.lst_price if line.product_id else 0
|
||
),
|
||
}
|
||
return vals
|
||
|
||
def _post_quotation_message(self, sale_order, triggered_rules):
|
||
"""Post a chatter message summarizing the assessment."""
|
||
self.ensure_one()
|
||
|
||
lines_html = ''
|
||
for line in self.line_ids:
|
||
icon = '⚡' if line.is_upcharge else '✅'
|
||
lines_html += (
|
||
f'<li>{icon} {line.product_name or line.adp_device_code} '
|
||
f'x{line.quantity} — ${line.unit_price:.2f}</li>'
|
||
)
|
||
|
||
measurements_html = ''
|
||
if self.seat_width:
|
||
measurements_html += (
|
||
f'<li>Seat Width: {self.seat_width} {self.seat_width_unit}</li>')
|
||
if self.seat_depth:
|
||
measurements_html += (
|
||
f'<li>Seat Depth: {self.seat_depth} {self.seat_depth_unit}</li>')
|
||
if self.finished_seat_to_floor_height:
|
||
measurements_html += (
|
||
f'<li>Seat to Floor: {self.finished_seat_to_floor_height} '
|
||
f'{self.seat_to_floor_unit}</li>')
|
||
if self.back_cane_height:
|
||
measurements_html += (
|
||
f'<li>Back Cane Height: {self.back_cane_height} '
|
||
f'{self.cane_height_unit}</li>')
|
||
if self.finished_back_height:
|
||
measurements_html += (
|
||
f'<li>Finished Back Height: {self.finished_back_height} '
|
||
f'{self.back_height_unit}</li>')
|
||
if self.finished_leg_rest_length:
|
||
measurements_html += (
|
||
f'<li>Leg Rest Length: {self.finished_leg_rest_length} '
|
||
f'{self.leg_rest_unit}</li>')
|
||
if self.client_weight:
|
||
measurements_html += (
|
||
f'<li>Client Weight: {self.client_weight} {self.client_weight_unit}</li>')
|
||
|
||
upcharges_html = ''
|
||
if triggered_rules:
|
||
upcharges_html = '<h4>Auto-Applied Upcharges</h4><ul>'
|
||
for rule in triggered_rules:
|
||
upcharges_html += (
|
||
f'<li>⚡ {rule.name} ({rule.adp_device_code})</li>')
|
||
upcharges_html += '</ul>'
|
||
|
||
body = Markup(
|
||
f'<h3>Wheelchair Assessment Completed</h3>'
|
||
f'<p>Assessment: {self.reference}<br/>'
|
||
f'Equipment: {dict(self._fields["equipment_type"].selection).get(self.equipment_type, "")}<br/>'
|
||
f'Build Type: {dict(self._fields["build_type"].selection).get(self.build_type, "")}</p>'
|
||
f'<h4>Measurements</h4><ul>{measurements_html}</ul>'
|
||
f'<h4>Selected Items</h4><ul>{lines_html}</ul>'
|
||
f'{upcharges_html}'
|
||
)
|
||
|
||
sale_order.message_post(body=body, message_type='comment',
|
||
subtype_xmlid='mail.mt_note')
|
||
|
||
# =========================================================================
|
||
# ACTIONS
|
||
# =========================================================================
|
||
def action_mark_review(self):
|
||
"""Mark assessment as ready for review."""
|
||
for rec in self:
|
||
rec.state = 'review'
|
||
|
||
def action_cancel(self):
|
||
"""Cancel the assessment."""
|
||
for rec in self:
|
||
rec.state = 'cancelled'
|
||
|
||
def action_reset_draft(self):
|
||
"""Reset to draft."""
|
||
for rec in self:
|
||
if rec.state == 'cancelled':
|
||
rec.state = 'draft'
|
||
|
||
def action_view_quotation(self):
|
||
"""Open the generated quotation."""
|
||
self.ensure_one()
|
||
if not self.sale_order_id:
|
||
raise UserError(_('No quotation has been generated yet.'))
|
||
return {
|
||
'type': 'ir.actions.act_window',
|
||
'res_model': 'sale.order',
|
||
'res_id': self.sale_order_id.id,
|
||
'view_mode': 'form',
|
||
'target': 'current',
|
||
}
|