455 lines
20 KiB
Python
455 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
||
|
||
from odoo import api, fields, models, _
|
||
from odoo.exceptions import UserError
|
||
import logging
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
# =========================================================================
|
||
# ADP device_type -> section code mapping
|
||
# Maps each device_type string from fusion.adp.device.code to the
|
||
# fusion.wc.section code where matched products should be placed.
|
||
# =========================================================================
|
||
DEVICE_TYPE_SECTION_MAP = {
|
||
# =================================================================
|
||
# MANUAL WHEELCHAIR FRAMES (Section 2b base devices)
|
||
# =================================================================
|
||
'Adult Standard Manual Type 1 Wheelchair': 'mw_frame',
|
||
'Adult Lightweight Standard Type 1 Wheelchair': 'mw_frame',
|
||
'Adult Lightweight Performance Type 3 Wheelchair': 'mw_frame',
|
||
'Adult Lightweight Performance Manual Wheelchair': 'mw_frame',
|
||
'Adult High Performance Rigid Type 4 Wheelchair': 'mw_frame',
|
||
'Adult High Performance Rigid Manual Wheelchair': 'mw_frame',
|
||
'Adult Manual Dynamic Tilt Type 5 Wheelchair': 'mw_frame',
|
||
'Adult Manual Dynamic Tilt Wheelchair': 'mw_frame',
|
||
'Standard Manual Wheelchair Frame with Manual Dynamic Tilt': 'mw_frame',
|
||
'Paediatric Lightweight Standard Type 1 Wheelchair': 'mw_frame',
|
||
'Paediatric Lightweight Performance Type 4 Wheelchair': 'mw_frame',
|
||
'Paediatric High Performance Rigid Type 4 Wheelchair': 'mw_frame',
|
||
'Paediatric High Performance Rigid Manual Wheelchair': 'mw_frame',
|
||
'Paediatric Manual Dynamic Tilt Type 5 Wheelchair': 'mw_frame',
|
||
'Paediatric Specific Specialty Stroller': 'mw_frame',
|
||
|
||
# =================================================================
|
||
# WALKER / ROLLATOR / AMBULATION AIDS FRAMES (Section 2a)
|
||
# =================================================================
|
||
'Adult Wheeled Walker Type 1': 'walker_frame',
|
||
'Adult Wheeled Walker Type 2': 'walker_frame',
|
||
'Adult Wheeled Walker Type 3': 'walker_frame',
|
||
'Paediatric Wheeled Walker Type 1': 'walker_frame',
|
||
'Paediatric Wheeled Walker Type 2': 'walker_frame',
|
||
'Paediatric Walking Frame': 'walker_frame',
|
||
'Paediatric Specific Walking Frame': 'walker_frame',
|
||
'Paediatric Standing Frame Type 1': 'walker_frame',
|
||
'Paediatric Standing Frame Type 2': 'walker_frame',
|
||
'Forearm Crutches': 'walker_frame',
|
||
'Walker Addons': 'walker_accessories',
|
||
|
||
# Walker ADP options (adolescent size upgrades)
|
||
'AA - Custom Modifications': 'walker_adp_options',
|
||
|
||
# =================================================================
|
||
# POWER BASE / SCOOTER FRAMES (Section 2c)
|
||
# =================================================================
|
||
'Adult Power Base Type 1': 'pw_frame',
|
||
'Adult Power Base Type 2': 'pw_frame',
|
||
'Adult Power Base Type 3': 'pw_frame',
|
||
'Paediatric Power Base Type 2': 'pw_frame',
|
||
'Paediatric Power Base Type 3': 'pw_frame',
|
||
'Power Scooter': 'pw_frame',
|
||
|
||
# Power base ADP funded options
|
||
'MW - Adjustable Tension Back Upholstery up to 18" Frame Width': 'pw_adp_options',
|
||
'MW - Adjustable Tension Back Upholstery over 18" Frame Width': 'pw_adp_options',
|
||
'PW - Adjustable Tension Back Upholstery up to 18" Frame Width': 'pw_adp_options',
|
||
'PW - Adjustable Tension Back Upholstery over 18" Frame Width': 'pw_adp_options',
|
||
'Midline Control': 'pw_adp_options',
|
||
'Manual Recline Option': 'pw_adp_options',
|
||
'Recliner Option': 'pw_adp_options',
|
||
'MW - Angle Adjustable Footplates (pair)': 'pw_adp_options',
|
||
'PW - Angle Adjustable Footplates (pair)': 'pw_adp_options',
|
||
'Manual Elevating Legrests (pair)': 'pw_adp_options',
|
||
'Elevating Legrests (pair)': 'pw_adp_options',
|
||
'Swingaway Mounting Bracket': 'pw_adp_options',
|
||
'One Piece 90/90 Front Riggings': 'pw_adp_options',
|
||
'Seat Package 1 for Power Bases': 'pw_adp_options',
|
||
'Seat Package 2 for Power Bases': 'pw_adp_options',
|
||
'PW - Oxygen Tank': 'pw_adp_options',
|
||
'MW - Oxygen Tank Holder': 'pw_adp_options',
|
||
'PW - Ventilator Tray': 'pw_adp_options',
|
||
'MW - Ventilator Tray': 'pw_adp_options',
|
||
|
||
# Power specialty controls (* require clinical rationale)
|
||
'Specialty Controls 1 Non Standard Joystick*': 'pw_specialty_controls',
|
||
'Specialty Controls 2 Chin/Rim Control*': 'pw_specialty_controls',
|
||
'Specialty Controls 3 Simple Touch*': 'pw_specialty_controls',
|
||
'Specialty Controls 4 Proximity Control*': 'pw_specialty_controls',
|
||
'Specialty Controls 5 Breath Control*': 'pw_specialty_controls',
|
||
'Specialty Controls 6 Scanners*': 'pw_specialty_controls',
|
||
'Auto Correction System*': 'pw_specialty_controls',
|
||
|
||
# Power positioning devices (require Justification for Funding Chart)
|
||
'Power Tilt Only': 'pw_positioning',
|
||
'Power Recline Only': 'pw_positioning',
|
||
'Power Tilt and Recline': 'pw_positioning',
|
||
'Power Elevating Footrests': 'pw_positioning',
|
||
'Multi-function Control Box': 'pw_positioning',
|
||
'Power Add-On Device': 'pw_positioning',
|
||
|
||
# =================================================================
|
||
# SEATING – SEAT CUSHION (shared across wheelchair types)
|
||
# =================================================================
|
||
'Seat Cushion': 'seat_cushion',
|
||
'Seat Cushion Cover(s)': 'seat_cushion_cover',
|
||
'Seat Options': 'seat_options',
|
||
'Seat Hardware': 'seat_hardware',
|
||
'Pommel/Adductors': 'pommel',
|
||
'Pommel Hardware': 'pommel_hardware',
|
||
|
||
# =================================================================
|
||
# SEATING – BACK SUPPORT
|
||
# =================================================================
|
||
'Back Support': 'back_support',
|
||
'Back Support Options': 'back_support_options',
|
||
'Back Cover': 'back_cover',
|
||
'Back Hardware': 'back_hardware',
|
||
|
||
# =================================================================
|
||
# SEATING – COMPLETE ASSEMBLY
|
||
# =================================================================
|
||
'Complete Assembly': 'complete_assembly',
|
||
|
||
# =================================================================
|
||
# SEATING – HEADREST / NECKREST
|
||
# =================================================================
|
||
'Headrest/Neckrest': 'headrest',
|
||
'Headrest/Neckrest Options': 'headrest_options',
|
||
'Headrest/Neckrest Hardware': 'headrest_hardware',
|
||
|
||
# =================================================================
|
||
# SEATING – POSITIONING BELTS
|
||
# =================================================================
|
||
'Positioning Belts': 'positioning_belts',
|
||
'Positioning Belts Options': 'positioning_belt_options',
|
||
|
||
# =================================================================
|
||
# SEATING – ARM SUPPORT
|
||
# =================================================================
|
||
'Arm Support(s)': 'arm_support',
|
||
'Arm Support Options': 'arm_support_options',
|
||
'Arm Support Hardware': 'arm_support_hardware',
|
||
|
||
# =================================================================
|
||
# SEATING – TRAY
|
||
# =================================================================
|
||
'Tray': 'tray',
|
||
'Tray Options': 'tray_options',
|
||
|
||
# =================================================================
|
||
# SEATING – LATERAL SUPPORT
|
||
# =================================================================
|
||
'Lateral Support(s)': 'lateral_support',
|
||
'Lateral Support Options': 'lateral_support_options',
|
||
'Lateral Support Hardware': 'lateral_support_hardware',
|
||
|
||
# =================================================================
|
||
# SEATING – FOOT / LEG SUPPORT
|
||
# =================================================================
|
||
'Foot/Leg Support(s)': 'foot_leg_support',
|
||
'Foot/Leg Support Options': 'foot_leg_support_options',
|
||
'Foot/Leg Support Hardware': 'foot_leg_support_hardware',
|
||
|
||
# =================================================================
|
||
# MANUAL WHEELCHAIR ACCESSORIES (Section 2b options/extras)
|
||
# =================================================================
|
||
'Amputee Axle Plates (pair)': 'mw_accessories',
|
||
'Caster Pin Locks (pair)': 'mw_accessories',
|
||
'Clothing Guards': 'mw_accessories',
|
||
'Grade Aids (pair)': 'mw_accessories',
|
||
'Heavy Duty Cross Braces & Upholstery': 'mw_accessories',
|
||
'One Arm/Lever Drive': 'mw_accessories',
|
||
'Plastic Coated Handrims': 'mw_accessories',
|
||
'Projected Handrims (pair)': 'mw_accessories',
|
||
'Quick Release Axles (pair)': 'mw_accessories',
|
||
'Spoke Protectors (pair)': 'mw_accessories',
|
||
'Stroller Handles/Paediatric': 'mw_accessories',
|
||
'Titanium Frame *': 'mw_accessories',
|
||
'Uni-Lateral Wheel Lock': 'mw_accessories',
|
||
'Unilateral Hand Brake': 'mw_accessories',
|
||
|
||
# =================================================================
|
||
# MW ADP UPCHARGE / MODIFICATION CODES
|
||
# =================================================================
|
||
'MW - Seat Width Required is Greater Than 18"': 'mw_adp_options',
|
||
'MW - Seat Depth Required is Greater Than 18"': 'mw_adp_options',
|
||
'MW - Heavy Duty Model, Client Weight Exceeds 250 Lbs': 'mw_adp_options',
|
||
'MW - Heavy Duty Model, Client Weight Exceeds 350 Lbs': 'mw_adp_options',
|
||
'MW - Heavy Duty Model, Client Weight Exceeds 400 Lbs': 'mw_adp_options',
|
||
'MW - Custom Modifications': 'mw_adp_options',
|
||
|
||
# =================================================================
|
||
# PW ADP UPCHARGE / MODIFICATION CODES
|
||
# =================================================================
|
||
'PW - Seat Width Required is Greater Than 18"': 'pw_adp_options',
|
||
'PW - Seat Depth Required is Greater Than 18"': 'pw_adp_options',
|
||
'PW - Heavy Duty Model, Client Weight Exceeds 250 Lbs': 'pw_adp_options',
|
||
'PW - Heavy Duty Model, Client Weight Exceeds 350 Lbs': 'pw_adp_options',
|
||
'PW - Heavy Duty Model, Client Weight Exceeds 400 Lbs': 'pw_adp_options',
|
||
'PW - Custom Modifications': 'pw_adp_options',
|
||
'SE - Custom Modifications': 'pw_adp_options',
|
||
}
|
||
|
||
|
||
class WheelchairSection(models.Model):
|
||
_name = 'fusion.wc.section'
|
||
_description = 'Wheelchair Configuration Section'
|
||
_order = 'sequence, id'
|
||
|
||
name = fields.Char(string='Name', required=True)
|
||
code = fields.Char(string='Code', required=True, index=True,
|
||
help='Unique identifier e.g. frame, cushion, backrest')
|
||
sequence = fields.Integer(string='Sequence', default=10)
|
||
active = fields.Boolean(string='Active', default=True)
|
||
|
||
equipment_type = fields.Selection(
|
||
selection='_get_equipment_type_selection',
|
||
string='Equipment Type', default='both', required=True)
|
||
|
||
@api.model
|
||
def _get_equipment_type_selection(self):
|
||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||
if types:
|
||
result = [(t.code, t.name) for t in types]
|
||
# Group options for shared sections
|
||
result.append(('wheelchair', 'All Wheelchairs (Manual + Power)'))
|
||
result.append(('both', 'All Equipment Types'))
|
||
return result
|
||
return [
|
||
('manual_wheelchair', 'Manual Wheelchair'),
|
||
('power_wheelchair', 'Power Wheelchair'),
|
||
('walker', 'Walker / Ambulation Aid'),
|
||
('wheelchair', 'All Wheelchairs (Manual + Power)'),
|
||
('both', 'All Equipment Types'),
|
||
]
|
||
|
||
icon = fields.Char(string='Icon', help='FontAwesome icon class e.g. fa-wheelchair')
|
||
description = fields.Text(string='Description',
|
||
help='Help text shown to sales reps during assessment')
|
||
|
||
# Section behavior flags
|
||
is_adp_options_section = fields.Boolean(string='ADP Options Section',
|
||
help='If true, this section shows as a checkbox grid of ADP funded options')
|
||
has_build_type = fields.Boolean(string='Has Build Type',
|
||
help='If true, shows Modular / Custom Fabricated toggle for items in this section')
|
||
allow_multiple = fields.Boolean(string='Allow Multiple', default=True,
|
||
help='Can select multiple products in this section')
|
||
required = fields.Boolean(string='Required', default=False,
|
||
help='Must have at least one selection')
|
||
|
||
# Measurement fields configuration
|
||
has_width = fields.Boolean(string='Collect Width')
|
||
has_depth = fields.Boolean(string='Collect Depth')
|
||
has_height = fields.Boolean(string='Collect Height')
|
||
has_length = fields.Boolean(string='Collect Length')
|
||
|
||
width_label = fields.Char(string='Width Label', default='Width (inches)')
|
||
depth_label = fields.Char(string='Depth Label', default='Depth (inches)')
|
||
height_label = fields.Char(string='Height Label', default='Height (inches)')
|
||
length_label = fields.Char(string='Length Label', default='Length (inches)')
|
||
|
||
# Product filter for custom search
|
||
product_category_id = fields.Many2one('product.category',
|
||
string='Product Category Filter',
|
||
help='Default category to filter products when searching in this section')
|
||
|
||
# Hierarchy
|
||
parent_id = fields.Many2one('fusion.wc.section', string='Parent Section',
|
||
ondelete='cascade', index=True)
|
||
child_ids = fields.One2many('fusion.wc.section', 'parent_id',
|
||
string='Sub-Sections')
|
||
|
||
# Options
|
||
option_ids = fields.One2many('fusion.wc.section.option', 'section_id',
|
||
string='Product Options')
|
||
option_count = fields.Integer(string='Options', compute='_compute_option_count')
|
||
|
||
@api.depends('option_ids')
|
||
def _compute_option_count(self):
|
||
for record in self:
|
||
record.option_count = len(record.option_ids)
|
||
|
||
@api.depends('name', 'code')
|
||
def _compute_display_name(self):
|
||
for record in self:
|
||
if record.parent_id:
|
||
record.display_name = f"{record.parent_id.name} / {record.name}"
|
||
else:
|
||
record.display_name = record.name or ''
|
||
|
||
# =====================================================================
|
||
# AUTO-POPULATE OPTIONS FROM INVENTORY
|
||
# =====================================================================
|
||
def action_auto_populate_options(self):
|
||
"""Auto-populate product options for THIS section by matching
|
||
products in inventory whose ADP device code maps to this section
|
||
via the ADP device type lookup.
|
||
|
||
Called from a button on the section form view.
|
||
"""
|
||
self.ensure_one()
|
||
stats = self._populate_section_from_products()
|
||
return {
|
||
'type': 'ir.actions.client',
|
||
'tag': 'display_notification',
|
||
'params': {
|
||
'title': _('Auto-Populate Complete'),
|
||
'message': _(
|
||
'Section "%(section)s": %(added)d products added, '
|
||
'%(skipped)d already existed, %(unmapped)d unmapped.',
|
||
section=self.name,
|
||
added=stats['added'],
|
||
skipped=stats['skipped'],
|
||
unmapped=stats['unmapped'],
|
||
),
|
||
'type': 'success',
|
||
'sticky': False,
|
||
},
|
||
}
|
||
|
||
@api.model
|
||
def action_auto_populate_all_sections(self):
|
||
"""Auto-populate ALL sections at once. Called from a menu action."""
|
||
sections = self.search([])
|
||
totals = {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||
section_results = []
|
||
|
||
for section in sections:
|
||
stats = section._populate_section_from_products()
|
||
totals['added'] += stats['added']
|
||
totals['skipped'] += stats['skipped']
|
||
totals['unmapped'] += stats['unmapped']
|
||
if stats['added'] > 0:
|
||
section_results.append(f"{section.name}: +{stats['added']}")
|
||
|
||
details = ', '.join(section_results) if section_results else 'No new products found'
|
||
|
||
return {
|
||
'type': 'ir.actions.client',
|
||
'tag': 'display_notification',
|
||
'params': {
|
||
'title': _('Auto-Populate All Sections Complete'),
|
||
'message': _(
|
||
'Total: %(added)d products added across all sections. '
|
||
'%(skipped)d already existed. '
|
||
'%(unmapped)d products had unmapped device types.\n'
|
||
'%(details)s',
|
||
added=totals['added'],
|
||
skipped=totals['skipped'],
|
||
unmapped=totals['unmapped'],
|
||
details=details,
|
||
),
|
||
'type': 'success',
|
||
'sticky': True,
|
||
},
|
||
}
|
||
|
||
def _populate_section_from_products(self):
|
||
"""Find products whose ADP device type maps to this section,
|
||
and create one fusion.wc.section.option per product *template*.
|
||
|
||
Variants (size, colour, etc.) live under the template and are
|
||
selected when the sales rep adds the item to an assessment.
|
||
This keeps the option list compact and manageable.
|
||
|
||
Returns dict with counts: {added, skipped, unmapped}
|
||
"""
|
||
self.ensure_one()
|
||
SectionOption = self.env['fusion.wc.section.option'].sudo()
|
||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||
ProductTemplate = self.env['product.template'].sudo()
|
||
|
||
# Build reverse map: which device_types map to THIS section's code
|
||
my_device_types = [
|
||
dtype for dtype, section_code in DEVICE_TYPE_SECTION_MAP.items()
|
||
if section_code == self.code
|
||
]
|
||
|
||
if not my_device_types:
|
||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||
|
||
# Find ADP device codes with matching device_type
|
||
matching_adp_codes = ADPDevice.search([
|
||
('device_type', 'in', my_device_types),
|
||
('active', '=', True),
|
||
])
|
||
adp_code_strings = matching_adp_codes.mapped('device_code')
|
||
|
||
if not adp_code_strings:
|
||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||
|
||
# Find product templates with those ADP codes
|
||
products = ProductTemplate.search([
|
||
('x_fc_adp_device_code', 'in', adp_code_strings),
|
||
])
|
||
|
||
if not products:
|
||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||
|
||
# Get existing option template IDs for this section (avoid duplicates)
|
||
existing_tmpl_ids = set(
|
||
SectionOption.search([
|
||
('section_id', '=', self.id),
|
||
]).mapped('product_tmpl_id.id')
|
||
)
|
||
|
||
# Pre-fetch build types: ADP code -> build_type
|
||
build_type_cache = {}
|
||
for adp_code_str in set(products.mapped('x_fc_adp_device_code')):
|
||
adp_device = ADPDevice.search([
|
||
('device_code', '=', adp_code_str),
|
||
('active', '=', True),
|
||
], limit=1)
|
||
build_type = 'both'
|
||
if adp_device and adp_device.build_type:
|
||
if adp_device.build_type == 'modular':
|
||
build_type = 'modular'
|
||
elif adp_device.build_type == 'custom_fabricated':
|
||
build_type = 'custom_fabricated'
|
||
build_type_cache[adp_code_str] = build_type
|
||
|
||
added = 0
|
||
skipped = 0
|
||
unmapped = 0
|
||
|
||
# Batch-create one option record per product template
|
||
vals_list = []
|
||
for tmpl in products:
|
||
if tmpl.id in existing_tmpl_ids:
|
||
skipped += 1
|
||
continue
|
||
|
||
adp_code = tmpl.x_fc_adp_device_code
|
||
build_type = build_type_cache.get(adp_code, 'both')
|
||
|
||
vals_list.append({
|
||
'section_id': self.id,
|
||
'product_tmpl_id': tmpl.id,
|
||
'is_standard': True,
|
||
'available_build_types': build_type,
|
||
'sequence': 10 + added,
|
||
})
|
||
added += 1
|
||
existing_tmpl_ids.add(tmpl.id)
|
||
|
||
# Bulk create for performance
|
||
if vals_list:
|
||
SectionOption.create(vals_list)
|
||
|
||
_logger.info(
|
||
'Section "%s" auto-populate: %d product templates added, '
|
||
'%d already existed, %d unmapped',
|
||
self.name, added, skipped, unmapped,
|
||
)
|
||
return {'added': added, 'skipped': skipped, 'unmapped': unmapped}
|