Initial commit
This commit is contained in:
874
fusion_authorizer_portal/models/accessibility_assessment.py
Normal file
874
fusion_authorizer_portal/models/accessibility_assessment.py
Normal file
@@ -0,0 +1,874 @@
|
||||
# -*- 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'})
|
||||
Reference in New Issue
Block a user