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