This commit is contained in:
gsinghpal
2026-03-09 15:21:22 -04:00
parent a3e85a23ef
commit acd3fc455e
243 changed files with 20459 additions and 4197 deletions

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from . import equipment_type
from . import wc_section
from . import wc_section_option
from . import wc_upcharge_rule
from . import wc_assessment
from . import wc_assessment_line
from . import sale_order
from . import wc_config_flow
from . import wc_config_flow_node
from . import wc_config_flow_connection
from . import wc_config_flow_node_option
from . import wc_config_flow_step

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class EquipmentType(models.Model):
_name = 'fusion.equipment.type'
_description = 'Equipment Type'
_order = 'sequence, id'
name = fields.Char(string='Name', required=True,
help='e.g. "Manual Wheelchair", "Stair Lift", "Porch Lift"')
code = fields.Char(string='Code', required=True, index=True,
help='Technical code used in Selection fields, e.g. "manual_wheelchair", "stair_lift"')
icon = fields.Char(string='Icon', default='fa-cog',
help='FontAwesome icon class, e.g. "fa-wheelchair", "fa-arrow-up"')
description = fields.Text(string='Description')
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
_sql_constraints = [
('code_unique', 'unique(code)', 'Equipment type code must be unique.'),
]

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
wc_assessment_id = fields.Many2one('fusion.wc.assessment',
string='Wheelchair Assessment', copy=False,
help='The wheelchair assessment that generated this quotation')

View File

@@ -0,0 +1,849 @@
# -*- 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 = '&#x26A1;' if line.is_upcharge else '&#x2705;'
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>&#x26A1; {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',
}

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class WheelchairAssessmentLine(models.Model):
_name = 'fusion.wc.assessment.line'
_description = 'Wheelchair Assessment Line Item'
_order = 'section_sequence, sequence, id'
assessment_id = fields.Many2one('fusion.wc.assessment', string='Assessment',
required=True, ondelete='cascade', index=True)
section_id = fields.Many2one('fusion.wc.section', string='Section', index=True)
section_sequence = fields.Integer(
related='section_id.sequence', store=True, string='Section Order')
sequence = fields.Integer(string='Sequence', default=10)
# Product
product_id = fields.Many2one('product.product', string='Product', index=True)
product_name = fields.Char(string='Description',
help='Can override product name with custom description')
quantity = fields.Float(string='Quantity', default=1.0)
# ADP info
adp_device_code = fields.Char(string='ADP Code')
adp_price = fields.Float(string='ADP Price', digits='Product Price')
unit_price = fields.Float(string='Selling Price', digits='Product Price')
# Build type (for seating items)
build_type = fields.Selection([
('modular', 'Modular'),
('custom_fabricated', 'Custom Fabricated'),
], string='Build Type')
# Clinical rationale (required for certain items)
clinical_rationale = fields.Text(string='Clinical Rationale')
# Computed pricing
subtotal = fields.Float(string='Subtotal',
compute='_compute_subtotal', store=True, digits='Product Price')
adp_portion = fields.Float(string='ADP Portion',
compute='_compute_portions', store=True, digits='Product Price')
client_portion = fields.Float(string='Client Portion',
compute='_compute_portions', store=True, digits='Product Price')
# Upcharge tracking
is_upcharge = fields.Boolean(string='Auto-Applied Upcharge', default=False)
upcharge_rule_id = fields.Many2one('fusion.wc.upcharge.rule',
string='Triggered By Rule')
upcharge_reason = fields.Char(string='Upcharge Reason')
# Section-specific measurements
width = fields.Float(string='Width', digits=(10, 2))
depth = fields.Float(string='Depth', digits=(10, 2))
height = fields.Float(string='Height', digits=(10, 2))
notes = fields.Text(string='Notes')
@api.depends('unit_price', 'quantity')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.unit_price * line.quantity
@api.depends('subtotal', 'adp_price', 'quantity', 'assessment_id.client_type')
def _compute_portions(self):
"""Estimate ADP and client portions based on client type and ADP price."""
for line in self:
if not line.adp_price or not line.subtotal:
line.adp_portion = 0.0
line.client_portion = line.subtotal
continue
client_type = line.assessment_id.client_type or 'reg'
adp_base = line.adp_price * line.quantity
# Determine ADP coverage percentage
if client_type == 'reg':
# REG: 75% ADP, 25% client
adp_amount = adp_base * 0.75
else:
# ODS, ACS, OWP, etc.: 100% ADP
adp_amount = adp_base
# Client pays the rest (including anything above ADP price)
line.adp_portion = min(adp_amount, line.subtotal)
line.client_portion = line.subtotal - line.adp_portion
@api.onchange('product_id')
def _onchange_product_id(self):
"""Auto-fill ADP code and pricing from product."""
if self.product_id:
tmpl = self.product_id.product_tmpl_id
self.adp_device_code = tmpl.x_fc_adp_device_code or ''
self.adp_price = tmpl.x_fc_adp_price or 0.0
self.unit_price = self.product_id.lst_price
if not self.product_name:
self.product_name = self.product_id.display_name

View File

@@ -0,0 +1,539 @@
# -*- coding: utf-8 -*-
import json
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class WheelchairConfigFlow(models.Model):
_name = 'fusion.wc.config.flow'
_description = 'Wheelchair Configuration Flow'
_order = 'sequence, id'
name = fields.Char(string='Name', required=True,
help='e.g. "Standard Manual Wheelchair Config"')
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)
equipment_type = fields.Selection(
selection='_get_equipment_type_selection',
string='Equipment Type', required=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'),
('walker', 'Walker / Ambulation Aid'),
]
description = fields.Text(string='Description')
# Canvas state — JSON blob for viewport (zoom, pan) preserved across sessions
canvas_data = fields.Text(string='Canvas Data', default='{}',
help='JSON: viewport state for the visual designer')
state = fields.Selection([
('draft', 'Draft'),
('active', 'Active'),
('archived', 'Archived'),
], string='Status', default='draft', tracking=True)
# Relationships
node_ids = fields.One2many('fusion.wc.config.flow.node', 'flow_id',
string='Nodes')
connection_ids = fields.One2many('fusion.wc.config.flow.connection', 'flow_id',
string='Connections')
step_ids = fields.One2many('fusion.wc.config.flow.step', 'flow_id',
string='Form Steps')
# Computed counts
node_count = fields.Integer(string='Nodes', compute='_compute_counts')
connection_count = fields.Integer(string='Connections', compute='_compute_counts')
step_count = fields.Integer(string='Steps', compute='_compute_counts')
@api.depends('node_ids', 'connection_ids', 'step_ids')
def _compute_counts(self):
for rec in self:
rec.node_count = len(rec.node_ids)
rec.connection_count = len(rec.connection_ids)
rec.step_count = len(rec.step_ids)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_open_designer(self):
"""Open the visual flow designer (OWL client action)."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_flow_designer',
'name': _('Flow Designer: %s') % self.name,
'context': {'active_id': self.id},
}
def action_activate(self):
"""Set flow to active. Deactivate other flows for same equipment type.
Auto-creates default steps if flow has no steps defined.
"""
self.ensure_one()
# Auto-create default steps if none exist
if not self.step_ids:
self.action_create_default_steps()
# Deactivate other active flows for the same equipment type
siblings = self.search([
('equipment_type', '=', self.equipment_type),
('state', '=', 'active'),
('id', '!=', self.id),
])
siblings.write({'state': 'draft'})
self.write({'state': 'active'})
def action_archive(self):
self.ensure_one()
self.write({'state': 'archived'})
def action_reset_draft(self):
self.ensure_one()
self.write({'state': 'draft'})
def action_create_default_steps(self):
"""Generate default form steps based on equipment type.
For wheelchair types, creates the standard 6 steps.
For other types, creates a generic 4-step flow.
"""
self.ensure_one()
Step = self.env['fusion.wc.config.flow.step']
# Remove existing steps
self.step_ids.unlink()
wheelchair_types = ('manual_wheelchair', 'power_wheelchair')
if self.equipment_type in wheelchair_types:
# ── Client step config: ADP program fields ──
is_manual = self.equipment_type == 'manual_wheelchair'
client_config = json.dumps({
"show_health_card": True,
"show_dob": True,
"show_adp_fields": True, # client_type + reason_for_application
"show_wheelchair_category": is_manual,
"show_powerchair_category": not is_manual,
})
# ── Measurement fields ──
mw_measurements_json = json.dumps([
{"name": "seat_width", "label": "Seat Width", "type": "float",
"unit_field": "seat_width_unit", "units": ["inches", "cm"], "required": True},
{"name": "seat_depth", "label": "Seat Depth", "type": "float",
"unit_field": "seat_depth_unit", "units": ["inches", "cm"], "required": True},
{"name": "finished_seat_to_floor_height", "label": "Finished Seat to Floor Height",
"type": "float", "unit_field": "seat_to_floor_unit", "units": ["inches", "cm"]},
{"name": "back_cane_height", "label": "Back Cane Height", "type": "float",
"unit_field": "cane_height_unit", "units": ["inches", "cm"]},
{"name": "finished_back_height", "label": "Finished Back Height", "type": "float",
"unit_field": "back_height_unit", "units": ["inches", "cm"]},
{"name": "finished_leg_rest_length", "label": "Finished Leg Rest Length",
"type": "float", "unit_field": "leg_rest_unit", "units": ["inches", "cm"]},
{"name": "client_weight", "label": "Client Weight", "type": "float",
"unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True},
])
prefix = 'mw' if is_manual else 'pw'
steps = [
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
'icon': 'fa-user', 'fields_json': client_config},
{'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements',
'icon': 'fa-ruler', 'fields_json': mw_measurements_json},
{'sequence': 30, 'name': 'Frame', 'step_type': 'product_select',
'icon': 'fa-wheelchair', 'section_code': f'{prefix}_frame'},
{'sequence': 40, 'name': 'Seating', 'step_type': 'options',
'icon': 'fa-chair', 'section_code': 'seating'},
{'sequence': 50, 'name': 'Options', 'step_type': 'options',
'icon': 'fa-list', 'section_code': f'{prefix}_adp_options,{prefix}_accessories'},
{'sequence': 60, 'name': 'Review', 'step_type': 'review',
'icon': 'fa-check'},
]
elif self.equipment_type == 'walker':
# ── Client step config: ADP program fields (no wheelchair categories) ──
client_config = json.dumps({
"show_health_card": True,
"show_dob": True,
"show_adp_fields": True,
})
walker_measurements_json = json.dumps([
{"name": "walker_seat_height", "label": "Seat Height", "type": "float",
"unit_field": "walker_seat_height_unit", "units": ["inches", "cm", "na"]},
{"name": "push_handle_height", "label": "Push Handle Height", "type": "float",
"unit_field": "push_handle_height_unit", "units": ["inches", "cm"]},
{"name": "width_between_push_handles", "label": "Width Between Push Handles",
"type": "float", "unit_field": "push_handle_width_unit", "units": ["inches", "cm"]},
{"name": "client_weight", "label": "Client Weight", "type": "float",
"unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True},
])
steps = [
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
'icon': 'fa-user', 'fields_json': client_config},
{'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements',
'icon': 'fa-ruler', 'fields_json': walker_measurements_json},
{'sequence': 30, 'name': 'Equipment', 'step_type': 'product_select',
'icon': 'fa-male', 'section_code': 'walker_frame'},
{'sequence': 40, 'name': 'Options', 'step_type': 'options',
'icon': 'fa-list', 'section_code': 'walker_adp_options,walker_accessories'},
{'sequence': 50, 'name': 'Review', 'step_type': 'review',
'icon': 'fa-check'},
]
else:
# ── Generic flow for new equipment types (stair lift, porch lift, etc.) ──
# No ADP fields, no health card, no DOB — pure quotation tool
steps = [
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
'icon': 'fa-user'}, # no fields_json → no optional groups
{'sequence': 20, 'name': 'Equipment', 'step_type': 'product_select',
'icon': 'fa-cog'},
{'sequence': 30, 'name': 'Options', 'step_type': 'options',
'icon': 'fa-list'},
{'sequence': 40, 'name': 'Review', 'step_type': 'review',
'icon': 'fa-check'},
]
for step_vals in steps:
step_vals['flow_id'] = self.id
Step.create(step_vals)
def action_new_assessment_form(self):
"""Open a new portal assessment form pre-selected to this flow's equipment type."""
self.ensure_one()
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
return {
'type': 'ir.actions.act_url',
'url': f'{base}/my/quotation/builder/new?equipment_type={self.equipment_type}',
'target': 'new',
}
# ------------------------------------------------------------------
# Load / Save graph for designer
# ------------------------------------------------------------------
def load_flow_graph(self):
"""Return the complete flow graph for the visual designer."""
self.ensure_one()
nodes = []
for n in self.node_ids:
node_data = {
'id': n.id,
'name': n.name,
'node_type': n.node_type,
'pos_x': n.pos_x,
'pos_y': n.pos_y,
'color': n.color,
'icon': n.icon,
'section_id': n.section_id.id if n.section_id else False,
'section_name': n.section_id.name if n.section_id else '',
'decision_field': n.decision_field or '',
'decision_operator': n.decision_operator or '',
'decision_value': n.decision_value or '',
'measurement_field': n.measurement_field or '',
'comparison': n.comparison or '',
'threshold_value': n.threshold_value,
'action_type': n.action_type or '',
'target_option_ids': n.target_option_ids.ids,
'target_step': n.target_step,
'config_json': n.config_json or '{}',
'node_options': [{
'id': opt.id,
'name': opt.name,
'sequence': opt.sequence,
'section_option_id': opt.section_option_id.id if opt.section_option_id else False,
'enables_option_ids': opt.enables_option_ids.ids,
'disables_option_ids': opt.disables_option_ids.ids,
'requires_option_ids': opt.requires_option_ids.ids,
'port_key': opt.port_key,
} for opt in n.node_option_ids],
}
nodes.append(node_data)
connections = []
for c in self.connection_ids:
connections.append({
'id': c.id,
'source_node_id': c.source_node_id.id,
'target_node_id': c.target_node_id.id,
'source_port': c.source_port or 'out',
'label': c.label or '',
'condition_json': c.condition_json or '{}',
'sequence': c.sequence,
})
return {
'id': self.id,
'name': self.name,
'equipment_type': self.equipment_type,
'canvas': json.loads(self.canvas_data or '{}'),
'nodes': nodes,
'connections': connections,
}
def save_flow_graph(self, graph_data):
"""Sync the full graph from the visual designer to ORM records."""
self.ensure_one()
Node = self.env['fusion.wc.config.flow.node']
Connection = self.env['fusion.wc.config.flow.connection']
# Save viewport state
canvas = graph_data.get('canvas', {})
self.write({'canvas_data': json.dumps(canvas)})
# --- Sync Nodes ---
incoming_nodes = graph_data.get('nodes', [])
incoming_node_ids = set()
node_id_map = {} # temp_id -> real_id
for ndata in incoming_nodes:
vals = {
'flow_id': self.id,
'name': ndata.get('name', 'Untitled'),
'node_type': ndata.get('node_type', 'action'),
'pos_x': ndata.get('pos_x', 0),
'pos_y': ndata.get('pos_y', 0),
'color': ndata.get('color', '#3b82f6'),
'icon': ndata.get('icon', 'fa-circle'),
'section_id': ndata.get('section_id') or False,
'decision_field': ndata.get('decision_field') or False,
'decision_operator': ndata.get('decision_operator') or False,
'decision_value': ndata.get('decision_value') or '',
'measurement_field': ndata.get('measurement_field') or False,
'comparison': ndata.get('comparison') or False,
'threshold_value': ndata.get('threshold_value', 0),
'action_type': ndata.get('action_type') or False,
'target_step': ndata.get('target_step', 0),
'config_json': ndata.get('config_json', '{}'),
}
target_ids = ndata.get('target_option_ids', [])
node_id = ndata.get('id')
if isinstance(node_id, int) and node_id > 0:
# Update existing
node = Node.browse(node_id)
if node.exists():
node.write(vals)
if target_ids is not None:
node.write({'target_option_ids': [(6, 0, target_ids)]})
node_id_map[node_id] = node_id
incoming_node_ids.add(node_id)
continue
# Create new
new_node = Node.create(vals)
if target_ids:
new_node.write({'target_option_ids': [(6, 0, target_ids)]})
node_id_map[ndata.get('id', 'new')] = new_node.id
incoming_node_ids.add(new_node.id)
# Delete nodes no longer in the graph
existing_nodes = Node.search([('flow_id', '=', self.id)])
to_delete = existing_nodes.filtered(lambda n: n.id not in incoming_node_ids)
to_delete.unlink()
# --- Sync Connections ---
incoming_conns = graph_data.get('connections', [])
incoming_conn_ids = set()
for cdata in incoming_conns:
src_id = cdata.get('source_node_id')
tgt_id = cdata.get('target_node_id')
# Resolve temp IDs
src_id = node_id_map.get(src_id, src_id)
tgt_id = node_id_map.get(tgt_id, tgt_id)
vals = {
'flow_id': self.id,
'source_node_id': src_id,
'target_node_id': tgt_id,
'source_port': cdata.get('source_port', 'out'),
'label': cdata.get('label', ''),
'condition_json': cdata.get('condition_json', '{}'),
'sequence': cdata.get('sequence', 10),
}
conn_id = cdata.get('id')
if isinstance(conn_id, int) and conn_id > 0:
conn = Connection.browse(conn_id)
if conn.exists():
conn.write(vals)
incoming_conn_ids.add(conn_id)
continue
new_conn = Connection.create(vals)
incoming_conn_ids.add(new_conn.id)
# Delete connections no longer in the graph
existing_conns = Connection.search([('flow_id', '=', self.id)])
to_delete = existing_conns.filtered(lambda c: c.id not in incoming_conn_ids)
to_delete.unlink()
return True
# ------------------------------------------------------------------
# Flow Evaluation Engine
# ------------------------------------------------------------------
def evaluate(self, assessment_data):
"""Walk the flow graph and return option directives.
Args:
assessment_data: dict with keys like equipment_type, seat_width,
selected_option_ids, build_type, etc.
Returns:
dict with enabled/disabled/required option IDs, skip steps, messages.
"""
self.ensure_one()
result = {
'enabled_option_ids': set(),
'disabled_option_ids': set(),
'required_option_ids': set(),
'skip_steps': set(),
'active_nodes': [],
'messages': [],
}
# Find start node
start_nodes = self.node_ids.filtered(lambda n: n.node_type == 'start')
if not start_nodes:
return self._finalize_result(result)
# BFS walk with cycle protection
visited = set()
queue = list(start_nodes)
max_iterations = 200
iteration = 0
while queue and iteration < max_iterations:
iteration += 1
node = queue.pop(0)
if node.id in visited:
continue
visited.add(node.id)
result['active_nodes'].append(node.id)
next_nodes = self._evaluate_node(node, assessment_data, result)
queue.extend(next_nodes)
return self._finalize_result(result)
def _finalize_result(self, result):
"""Convert sets to sorted lists for JSON serialization."""
for key in ('enabled_option_ids', 'disabled_option_ids',
'required_option_ids', 'skip_steps'):
result[key] = sorted(result[key])
return result
def _evaluate_node(self, node, data, result):
"""Evaluate a single node. Returns list of next nodes to visit."""
if node.node_type == 'start':
return self._get_next_nodes(node, 'out')
elif node.node_type == 'end':
return []
elif node.node_type == 'decision':
passed = self._evaluate_decision(node, data)
return self._get_next_nodes(node, 'true' if passed else 'false')
elif node.node_type == 'measurement_check':
passed = self._evaluate_measurement(node, data)
return self._get_next_nodes(node, 'pass' if passed else 'fail')
elif node.node_type == 'option_group':
selected = set(data.get('selected_option_ids', []))
for opt in node.node_option_ids:
if opt.section_option_id and opt.section_option_id.id in selected:
result['enabled_option_ids'].update(opt.enables_option_ids.ids)
result['disabled_option_ids'].update(opt.disables_option_ids.ids)
result['required_option_ids'].update(opt.requires_option_ids.ids)
return self._get_next_nodes(node, 'out')
elif node.node_type == 'action':
if node.action_type == 'enable':
result['enabled_option_ids'].update(node.target_option_ids.ids)
elif node.action_type == 'disable':
result['disabled_option_ids'].update(node.target_option_ids.ids)
elif node.action_type == 'require':
result['required_option_ids'].update(node.target_option_ids.ids)
elif node.action_type == 'skip_step' and node.target_step:
result['skip_steps'].add(node.target_step)
elif node.action_type == 'set_value':
msg = json.loads(node.config_json or '{}').get('message', '')
if msg:
result['messages'].append(msg)
return self._get_next_nodes(node, 'out')
elif node.node_type == 'product_select':
return self._get_next_nodes(node, 'out')
return []
def _evaluate_decision(self, node, data):
"""Evaluate a decision node condition against assessment data."""
field = node.decision_field
op = node.decision_operator
expected = node.decision_value or ''
actual = data.get(field, '')
if isinstance(actual, (int, float)) and expected:
try:
expected_num = float(expected)
actual_num = float(actual)
if op == 'eq':
return actual_num == expected_num
elif op == 'neq':
return actual_num != expected_num
elif op == 'gt':
return actual_num > expected_num
elif op == 'gte':
return actual_num >= expected_num
elif op == 'lt':
return actual_num < expected_num
elif op == 'lte':
return actual_num <= expected_num
except (ValueError, TypeError):
pass
# String comparison
actual_str = str(actual)
if op == 'eq':
return actual_str == expected
elif op == 'neq':
return actual_str != expected
elif op == 'in':
return actual_str in [v.strip() for v in expected.split(',')]
return False
def _evaluate_measurement(self, node, data):
"""Evaluate a measurement check node."""
field = node.measurement_field
if not field:
return False
actual = data.get(field, 0)
try:
actual = float(actual)
except (ValueError, TypeError):
return False
threshold = node.threshold_value
comp = node.comparison
if comp == 'gt':
return actual > threshold
elif comp == 'gte':
return actual >= threshold
elif comp == 'lt':
return actual < threshold
elif comp == 'eq':
return abs(actual - threshold) < 0.001
elif comp == 'neq':
return abs(actual - threshold) >= 0.001
return False
def _get_next_nodes(self, node, port):
"""Get target nodes for outgoing connections from a specific port."""
connections = node.outgoing_connection_ids.filtered(
lambda c: c.source_port == port
)
return connections.mapped('target_node_id')

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class WheelchairConfigFlowConnection(models.Model):
_name = 'fusion.wc.config.flow.connection'
_description = 'Configuration Flow Connection'
_order = 'sequence, id'
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
required=True, ondelete='cascade', index=True)
source_node_id = fields.Many2one('fusion.wc.config.flow.node',
string='Source Node', required=True, ondelete='cascade', index=True)
target_node_id = fields.Many2one('fusion.wc.config.flow.node',
string='Target Node', required=True, ondelete='cascade', index=True)
source_port = fields.Char(string='Source Port', default='out',
help='Port key: out, true, false, or option port_key')
label = fields.Char(string='Label',
help='Text shown on the connection line')
condition_json = fields.Text(string='Condition', default='{}',
help='Optional condition as JSON for advanced routing')
sequence = fields.Integer(default=10)

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class WheelchairConfigFlowNode(models.Model):
_name = 'fusion.wc.config.flow.node'
_description = 'Configuration Flow Node'
_order = 'sequence, id'
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
required=True, ondelete='cascade', index=True)
name = fields.Char(string='Name', required=True, default='New Node')
sequence = fields.Integer(default=10)
node_type = fields.Selection([
('start', 'Start'),
('end', 'End'),
('decision', 'Decision (If/Then)'),
('option_group', 'Option Group'),
('product_select', 'Product Selection'),
('measurement_check', 'Measurement Check'),
('action', 'Action (Enable/Disable/Require)'),
], string='Type', required=True, default='action')
# Canvas position
pos_x = fields.Float(string='X Position', default=100.0)
pos_y = fields.Float(string='Y Position', default=100.0)
# Visual
color = fields.Char(string='Color', default='#3b82f6')
icon = fields.Char(string='Icon', default='fa-circle')
# Extensible config for type-specific settings
config_json = fields.Text(string='Configuration', default='{}',
help='Type-specific configuration as JSON')
# Link to existing section
section_id = fields.Many2one('fusion.wc.section', string='Section',
help='Link this node to a wheelchair section')
# Node options (for option_group / product_select)
node_option_ids = fields.One2many('fusion.wc.config.flow.node.option',
'node_id', string='Options')
# ── Decision node fields ──
decision_field = fields.Selection([
('equipment_type', 'Equipment Type'),
('wheelchair_type', 'Wheelchair Category'),
('powerchair_type', 'Power Chair Category'),
('build_type', 'Build Type'),
('client_type', 'Client Type'),
('reason_for_application', 'Reason for Application'),
('seat_width', 'Seat Width (inches)'),
('seat_depth', 'Seat Depth (inches)'),
('client_weight', 'Client Weight (lbs)'),
('back_height', 'Back Height (inches)'),
('seat_to_floor', 'Seat to Floor Height (inches)'),
('leg_rest_length', 'Leg Rest Length (inches)'),
('custom', 'Custom Expression'),
('custom_field', 'Custom Field (from equipment data)'),
], string='Decision Field')
custom_field_name = fields.Char(string='Custom Field Name',
help='JSON key to check in equipment_data_json (for custom_field decisions)')
decision_operator = fields.Selection([
('eq', '='),
('neq', '!='),
('gt', '>'),
('gte', '>='),
('lt', '<'),
('lte', '<='),
('in', 'In List'),
], string='Operator')
decision_value = fields.Char(string='Expected Value',
help='For "In List" use comma-separated values')
# ── Measurement check fields (reuses upcharge rule pattern) ──
measurement_field = fields.Selection([
('seat_width', 'Seat Width'),
('seat_depth', 'Seat Depth'),
('back_width', 'Backrest Width'),
('back_height', 'Back Height'),
('seat_to_floor', 'Seat to Floor Height'),
('leg_rest_length', 'Leg Rest Length'),
('client_weight', 'Client Weight'),
], string='Measurement')
comparison = fields.Selection([
('gt', 'Greater Than'),
('gte', 'Greater Than or Equal'),
('lt', 'Less Than'),
('eq', 'Equal To'),
('neq', 'Not Equal To'),
], string='Comparison')
threshold_value = fields.Float(string='Threshold')
# ── Action node fields ──
action_type = fields.Selection([
('enable', 'Enable Options'),
('disable', 'Disable Options'),
('require', 'Require Options'),
('skip_step', 'Skip Portal Step'),
('set_value', 'Set Field Value'),
], string='Action Type')
target_option_ids = fields.Many2many(
'fusion.wc.section.option',
'wc_flow_node_target_option_rel',
'node_id', 'option_id',
string='Target Options',
help='Section options affected by this action')
target_step = fields.Integer(string='Target Step',
help='Portal form step number to skip (for skip_step action)')
# ── Connections (computed for convenience) ──
outgoing_connection_ids = fields.One2many(
'fusion.wc.config.flow.connection', 'source_node_id',
string='Outgoing Connections')
incoming_connection_ids = fields.One2many(
'fusion.wc.config.flow.connection', 'target_node_id',
string='Incoming Connections')

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class WheelchairConfigFlowNodeOption(models.Model):
_name = 'fusion.wc.config.flow.node.option'
_description = 'Configuration Flow Node Option'
_order = 'sequence, id'
node_id = fields.Many2one('fusion.wc.config.flow.node', string='Node',
required=True, ondelete='cascade', index=True)
name = fields.Char(string='Name', required=True)
sequence = fields.Integer(default=10)
# Link to actual section product option
section_option_id = fields.Many2one('fusion.wc.section.option',
string='Section Option',
help='Link this choice to a wheelchair section product option')
# Effects when this option is selected
enables_option_ids = fields.Many2many(
'fusion.wc.section.option',
'wc_flow_node_opt_enables_rel',
'node_option_id', 'option_id',
string='Enables Options',
help='Section options to enable when this choice is selected')
disables_option_ids = fields.Many2many(
'fusion.wc.section.option',
'wc_flow_node_opt_disables_rel',
'node_option_id', 'option_id',
string='Disables Options',
help='Section options to disable when this choice is selected')
requires_option_ids = fields.Many2many(
'fusion.wc.section.option',
'wc_flow_node_opt_requires_rel',
'node_option_id', 'option_id',
string='Requires Options',
help='Section options that become required when this choice is selected')
# Port key for connections — auto-generated from sequence
port_key = fields.Char(string='Port Key', compute='_compute_port_key',
store=True)
@api.depends('sequence', 'node_id')
def _compute_port_key(self):
for record in self:
record.port_key = 'opt_%d' % (record.sequence or 0)

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class ConfigFlowStep(models.Model):
_name = 'fusion.wc.config.flow.step'
_description = 'Configuration Flow Form Step'
_order = 'sequence, id'
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(string='Sequence', default=10)
name = fields.Char(string='Step Name', required=True,
help='Display name shown in the step indicator, e.g. "Client Info", "Measurements"')
step_type = fields.Selection([
('client_info', 'Client Information'),
('measurements', 'Measurements'),
('product_select', 'Product Selection'),
('options', 'Options & Accessories'),
('review', 'Review & Submit'),
('custom', 'Custom Fields'),
], string='Step Type', required=True, default='custom')
icon = fields.Char(string='Icon', default='fa-circle',
help='FontAwesome icon class for the step indicator')
# For product_select / options steps — which section to filter by
section_id = fields.Many2one('fusion.wc.section', string='Section',
help='Link to a wheelchair/equipment section for product filtering')
section_code = fields.Char(string='Section Code',
help='Alternative to section_id — match section by code. '
'Supports comma-separated codes for options steps.')
# For measurements / custom steps — JSON field definitions
fields_json = fields.Text(string='Field Definitions (JSON)',
help='JSON array of field definitions for dynamic rendering. '
'Each entry: {"name": "field_name", "label": "Display Label", '
'"type": "float|integer|selection|char|text|boolean", '
'"unit": "inches", "required": true, '
'"options": [["value", "Label"], ...]}')
help_text = fields.Text(string='Help Text',
help='Instructions displayed at the top of this step')
is_required = fields.Boolean(string='Required', default=True,
help='If true, this step must be completed before proceeding')

View File

@@ -0,0 +1,454 @@
# -*- 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}

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class WheelchairSectionOption(models.Model):
_name = 'fusion.wc.section.option'
_description = 'Wheelchair Section Product Option'
_order = 'is_standard desc, sequence, id'
section_id = fields.Many2one('fusion.wc.section', string='Section',
required=True, ondelete='cascade', index=True)
# ── Parent product (template level) ──
product_tmpl_id = fields.Many2one('product.template',
string='Product', required=True, index=True,
help='The parent product. Variants (size, colour) are chosen '
'when the sales rep adds this item to an assessment.')
sequence = fields.Integer(string='Sequence', default=10)
is_standard = fields.Boolean(string='Standard Option', default=False,
help='Standard options are shown prominently; non-standard found via search')
# ── Variant count (computed) ──
variant_count = fields.Integer(string='Variants',
compute='_compute_variant_count')
# ── ADP info (from template) ──
adp_device_code = fields.Char(string='ADP Code',
related='product_tmpl_id.x_fc_adp_device_code', readonly=True)
adp_price = fields.Float(string='ADP Price',
related='product_tmpl_id.x_fc_adp_price', readonly=True)
list_price = fields.Float(string='Sale Price',
related='product_tmpl_id.list_price', readonly=True)
# Build type applicability (for seating sections)
available_build_types = fields.Selection([
('modular', 'Modular Only'),
('custom_fabricated', 'Custom Fabricated Only'),
('both', 'Both'),
], string='Build Types', default='both',
help='Which build types this option is available for')
requires_clinical_rationale = fields.Boolean(string='Requires Clinical Rationale',
help='Sales rep must provide clinical rationale when selecting this option')
# Compatibility rules
incompatible_option_ids = fields.Many2many(
'fusion.wc.section.option',
'wc_option_incompatible_rel',
'option_id', 'incompatible_option_id',
string='Incompatible With',
help='Cannot be selected together with these options')
requires_option_ids = fields.Many2many(
'fusion.wc.section.option',
'wc_option_requires_rel',
'option_id', 'required_option_id',
string='Requires Options',
help='These options must be selected first')
@api.depends('product_tmpl_id')
def _compute_variant_count(self):
for record in self:
if record.product_tmpl_id:
record.variant_count = record.product_tmpl_id.product_variant_count
else:
record.variant_count = 0
@api.depends('product_tmpl_id', 'section_id')
def _compute_display_name(self):
for record in self:
parts = []
if record.section_id:
parts.append(record.section_id.name)
if record.product_tmpl_id:
parts.append(record.product_tmpl_id.display_name)
record.display_name = ' / '.join(parts) if parts else ''

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class WheelchairUpchargeRule(models.Model):
_name = 'fusion.wc.upcharge.rule'
_description = 'Wheelchair Upcharge Rule'
_order = 'sequence, id'
name = fields.Char(string='Name', required=True,
help='e.g. "Width > 18 inches (WAMA)"')
active = fields.Boolean(string='Active', default=True)
sequence = fields.Integer(string='Sequence', default=10)
# Trigger configuration
trigger_type = fields.Selection([
('measurement', 'Measurement Threshold'),
('weight', 'Client Weight'),
('dimension_mismatch', 'Dimension Mismatch'),
], string='Trigger Type', required=True)
# For measurement triggers
measurement_field = fields.Selection([
('seat_width', 'Seat Width'),
('seat_depth', 'Seat Depth'),
('back_width', 'Backrest Width'),
('back_height', 'Back Height'),
('seat_to_floor', 'Seat to Floor Height'),
('leg_rest_length', 'Leg Rest Length'),
], string='Measurement Field')
comparison = fields.Selection([
('gt', 'Greater Than'),
('gte', 'Greater Than or Equal'),
('lt', 'Less Than'),
('eq', 'Equal To'),
('neq', 'Not Equal To'),
], string='Comparison', default='gt')
threshold_value = fields.Float(string='Threshold Value',
help='e.g. 18.0 for WAMA width threshold')
# For weight triggers
weight_min = fields.Float(string='Min Weight (lbs)',
help='Trigger when client weight exceeds this value')
weight_max = fields.Float(string='Max Weight (lbs)',
help='Upper bound (0 or empty = no upper limit)')
# For dimension mismatch
compare_field_1 = fields.Selection([
('seat_width', 'Seat Width'),
('back_width', 'Backrest Width'),
('seat_depth', 'Seat Depth'),
], string='Compare Field 1')
compare_field_2 = fields.Selection([
('seat_width', 'Seat Width'),
('back_width', 'Backrest Width'),
('seat_depth', 'Seat Depth'),
], string='Compare Field 2')
# What to add when triggered
adp_device_code = fields.Char(string='ADP Device Code', required=True,
help='ADP code to add (e.g. WAMA, WAMB, WAMF)')
product_id = fields.Many2one('product.product', string='Product to Add',
help='If set, this product will be added to the quotation. '
'If empty, a generic line is created from the ADP code.')
# Equipment type applicability
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]
result.append(('both', 'Both (All Types)'))
return result
return [
('manual_wheelchair', 'Manual Wheelchair'),
('power_wheelchair', 'Power Wheelchair'),
('both', 'Both'),
]
# Mutual exclusion
mutually_exclusive_group = fields.Char(string='Exclusive Group',
help='Rules in the same group are mutually exclusive '
'(only the highest-priority matching rule applies). '
'e.g. "weight" for WAMF/WAMG/WAMH')
description = fields.Text(string='Description',
help='Explanation shown to the sales rep when this rule triggers')
# Linked ADP device code record (for price lookup)
adp_device_code_id = fields.Many2one('fusion.adp.device.code',
string='ADP Device Code Record',
compute='_compute_adp_device_code_id', store=True)
@api.depends('adp_device_code')
def _compute_adp_device_code_id(self):
ADPDevice = self.env['fusion.adp.device.code'].sudo()
for record in self:
if record.adp_device_code:
record.adp_device_code_id = ADPDevice.search(
[('device_code', '=', record.adp_device_code), ('active', '=', True)],
limit=1
)
else:
record.adp_device_code_id = False