changes
This commit is contained in:
14
Work in Progress/fusion_quotations/models/__init__.py
Normal file
14
Work in Progress/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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
Work in Progress/fusion_quotations/models/equipment_type.py
Normal file
23
Work in Progress/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
Work in Progress/fusion_quotations/models/sale_order.py
Normal file
11
Work in Progress/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
Work in Progress/fusion_quotations/models/wc_assessment.py
Normal file
849
Work in Progress/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',
|
||||
}
|
||||
@@ -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
Work in Progress/fusion_quotations/models/wc_config_flow.py
Normal file
539
Work in Progress/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')
|
||||
@@ -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
Work in Progress/fusion_quotations/models/wc_config_flow_node.py
Normal file
127
Work in Progress/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')
|
||||
@@ -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)
|
||||
@@ -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
Work in Progress/fusion_quotations/models/wc_section.py
Normal file
454
Work in Progress/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}
|
||||
@@ -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
Work in Progress/fusion_quotations/models/wc_upcharge_rule.py
Normal file
109
Work in Progress/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