changes
This commit is contained in:
14
fusion_quotations/models/__init__.py
Normal file
14
fusion_quotations/models/__init__.py
Normal 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
|
||||
BIN
fusion_quotations/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_quotations/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_quotations/models/__pycache__/sale_order.cpython-312.pyc
Normal file
BIN
fusion_quotations/models/__pycache__/sale_order.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_quotations/models/__pycache__/wc_section.cpython-312.pyc
Normal file
BIN
fusion_quotations/models/__pycache__/wc_section.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
fusion_quotations/models/equipment_type.py
Normal file
23
fusion_quotations/models/equipment_type.py
Normal 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.'),
|
||||
]
|
||||
11
fusion_quotations/models/sale_order.py
Normal file
11
fusion_quotations/models/sale_order.py
Normal 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')
|
||||
849
fusion_quotations/models/wc_assessment.py
Normal file
849
fusion_quotations/models/wc_assessment.py
Normal 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 = '⚡' if line.is_upcharge else '✅'
|
||||
lines_html += (
|
||||
f'<li>{icon} {line.product_name or line.adp_device_code} '
|
||||
f'x{line.quantity} — ${line.unit_price:.2f}</li>'
|
||||
)
|
||||
|
||||
measurements_html = ''
|
||||
if self.seat_width:
|
||||
measurements_html += (
|
||||
f'<li>Seat Width: {self.seat_width} {self.seat_width_unit}</li>')
|
||||
if self.seat_depth:
|
||||
measurements_html += (
|
||||
f'<li>Seat Depth: {self.seat_depth} {self.seat_depth_unit}</li>')
|
||||
if self.finished_seat_to_floor_height:
|
||||
measurements_html += (
|
||||
f'<li>Seat to Floor: {self.finished_seat_to_floor_height} '
|
||||
f'{self.seat_to_floor_unit}</li>')
|
||||
if self.back_cane_height:
|
||||
measurements_html += (
|
||||
f'<li>Back Cane Height: {self.back_cane_height} '
|
||||
f'{self.cane_height_unit}</li>')
|
||||
if self.finished_back_height:
|
||||
measurements_html += (
|
||||
f'<li>Finished Back Height: {self.finished_back_height} '
|
||||
f'{self.back_height_unit}</li>')
|
||||
if self.finished_leg_rest_length:
|
||||
measurements_html += (
|
||||
f'<li>Leg Rest Length: {self.finished_leg_rest_length} '
|
||||
f'{self.leg_rest_unit}</li>')
|
||||
if self.client_weight:
|
||||
measurements_html += (
|
||||
f'<li>Client Weight: {self.client_weight} {self.client_weight_unit}</li>')
|
||||
|
||||
upcharges_html = ''
|
||||
if triggered_rules:
|
||||
upcharges_html = '<h4>Auto-Applied Upcharges</h4><ul>'
|
||||
for rule in triggered_rules:
|
||||
upcharges_html += (
|
||||
f'<li>⚡ {rule.name} ({rule.adp_device_code})</li>')
|
||||
upcharges_html += '</ul>'
|
||||
|
||||
body = Markup(
|
||||
f'<h3>Wheelchair Assessment Completed</h3>'
|
||||
f'<p>Assessment: {self.reference}<br/>'
|
||||
f'Equipment: {dict(self._fields["equipment_type"].selection).get(self.equipment_type, "")}<br/>'
|
||||
f'Build Type: {dict(self._fields["build_type"].selection).get(self.build_type, "")}</p>'
|
||||
f'<h4>Measurements</h4><ul>{measurements_html}</ul>'
|
||||
f'<h4>Selected Items</h4><ul>{lines_html}</ul>'
|
||||
f'{upcharges_html}'
|
||||
)
|
||||
|
||||
sale_order.message_post(body=body, message_type='comment',
|
||||
subtype_xmlid='mail.mt_note')
|
||||
|
||||
# =========================================================================
|
||||
# ACTIONS
|
||||
# =========================================================================
|
||||
def action_mark_review(self):
|
||||
"""Mark assessment as ready for review."""
|
||||
for rec in self:
|
||||
rec.state = 'review'
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel the assessment."""
|
||||
for rec in self:
|
||||
rec.state = 'cancelled'
|
||||
|
||||
def action_reset_draft(self):
|
||||
"""Reset to draft."""
|
||||
for rec in self:
|
||||
if rec.state == 'cancelled':
|
||||
rec.state = 'draft'
|
||||
|
||||
def action_view_quotation(self):
|
||||
"""Open the generated quotation."""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
raise UserError(_('No quotation has been generated yet.'))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
97
fusion_quotations/models/wc_assessment_line.py
Normal file
97
fusion_quotations/models/wc_assessment_line.py
Normal 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
|
||||
539
fusion_quotations/models/wc_config_flow.py
Normal file
539
fusion_quotations/models/wc_config_flow.py
Normal 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')
|
||||
28
fusion_quotations/models/wc_config_flow_connection.py
Normal file
28
fusion_quotations/models/wc_config_flow_connection.py
Normal 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)
|
||||
127
fusion_quotations/models/wc_config_flow_node.py
Normal file
127
fusion_quotations/models/wc_config_flow_node.py
Normal 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')
|
||||
51
fusion_quotations/models/wc_config_flow_node_option.py
Normal file
51
fusion_quotations/models/wc_config_flow_node_option.py
Normal 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)
|
||||
47
fusion_quotations/models/wc_config_flow_step.py
Normal file
47
fusion_quotations/models/wc_config_flow_step.py
Normal 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')
|
||||
454
fusion_quotations/models/wc_section.py
Normal file
454
fusion_quotations/models/wc_section.py
Normal 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}
|
||||
78
fusion_quotations/models/wc_section_option.py
Normal file
78
fusion_quotations/models/wc_section_option.py
Normal 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 ''
|
||||
109
fusion_quotations/models/wc_upcharge_rule.py
Normal file
109
fusion_quotations/models/wc_upcharge_rule.py
Normal 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
|
||||
Reference in New Issue
Block a user