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

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Quotation Builder',
'version': '19.0.2.0.0',
'category': 'Sales',
'summary': 'Multi-Equipment Assessment & Automatic Quotation Generation',
'description': """
Guided equipment assessment form with automatic quotation generation.
Supports multiple equipment types (wheelchairs, stair lifts, porch lifts, etc.)
with dynamic form steps per Configuration Flow.
- Dynamic equipment type registry
- Config Flow-driven form steps (customizable per equipment type)
- Configurable sections (Frame, Cushion, Backrest, etc.)
- Admin-managed product options per section
- Auto-upcharge rules based on measurements
- Multi-step portal form for sales reps
- ADP prescription measurements capture
- Automatic draft sale order generation with correct ADP device codes
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'sale',
'sale_management',
'portal',
'website',
'mail',
'fusion_claims',
'fusion_authorizer_portal',
],
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/equipment_type_data.xml',
'views/wc_section_views.xml',
'views/wc_upcharge_rule_views.xml',
'views/wc_assessment_views.xml',
'views/wc_config_flow_views.xml',
'views/equipment_type_views.xml',
'views/menus.xml',
'views/portal_quotation_templates.xml',
'data/section_seed_data.xml',
'data/upcharge_rules_data.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_quotations/static/src/css/quotation_form.css',
'fusion_quotations/static/src/js/quotation_form.js',
],
'web.assets_backend': [
'fusion_quotations/static/src/scss/flow_designer.scss',
'fusion_quotations/static/src/js/flow_designer/flow_designer_action.js',
'fusion_quotations/static/src/xml/flow_designer_templates.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import portal_quotation
from . import quotation_api

View File

@@ -0,0 +1,536 @@
# -*- coding: utf-8 -*-
from odoo import http, _
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
import json
import logging
_logger = logging.getLogger(__name__)
class QuotationPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'wc_assessment_count' in counters:
Assessment = request.env['fusion.wc.assessment'].sudo()
values['wc_assessment_count'] = Assessment.search_count([
('sales_rep_id', '=', request.env.user.id),
])
return values
# =========================================================================
# ASSESSMENT LIST
# =========================================================================
@http.route('/my/quotation/builder', type='http', auth='user', website=True)
def quotation_builder_list(self, **kw):
Assessment = request.env['fusion.wc.assessment'].sudo()
assessments = Assessment.search(
[('sales_rep_id', '=', request.env.user.id)],
order='create_date desc',
limit=50,
)
# Equipment types with active flows — for "New Assessment" dropdown
Flow = request.env['fusion.wc.config.flow'].sudo()
active_flow_etypes = Flow.search([
('state', '=', 'active'),
]).mapped('equipment_type')
all_equip_types = request.env['fusion.equipment.type'].sudo().search(
[('active', '=', True)], order='sequence')
equipment_types = all_equip_types.filtered(
lambda et: et.code in active_flow_etypes)
# Build lookup dict: code → name for template display
equip_type_map = {et.code: et.name for et in all_equip_types}
return request.render('fusion_quotations.portal_quotation_list', {
'assessments': assessments,
'equipment_types': equipment_types,
'equip_type_map': equip_type_map,
'page_name': 'wc_assessments',
})
# =========================================================================
# HELPER: build template context values
# =========================================================================
def _get_form_context(self, assessment=None, requested_equipment_type=None):
"""Return common template context for the assessment form.
Args:
assessment: existing assessment record, or None for new.
requested_equipment_type: equipment type code from query param
(only used when assessment is None — new form).
"""
from types import SimpleNamespace
sections = request.env['fusion.wc.section'].sudo().search(
[('active', '=', True), ('parent_id', '=', False)],
order='sequence',
)
countries = request.env['res.country'].sudo().search([])
provinces = request.env['res.country.state'].sudo().search(
[('country_id.code', '=', 'CA')])
# Equipment types — only show types that have at least one active config flow
Flow = request.env['fusion.wc.config.flow'].sudo()
all_equip_types = request.env['fusion.equipment.type'].sudo().search(
[('active', '=', True)], order='sequence')
active_flow_etypes = Flow.search([
('state', '=', 'active'),
]).mapped('equipment_type')
equipment_types = all_equip_types.filtered(
lambda et: et.code in active_flow_etypes)
# ── Flow steps ──
# Determine the equipment type: assessment field > query param > first available > fallback
FlowStep = request.env['fusion.wc.config.flow.step'].sudo()
if assessment:
equipment_type = assessment.equipment_type
elif requested_equipment_type and requested_equipment_type in active_flow_etypes:
equipment_type = requested_equipment_type
elif active_flow_etypes:
# Default to first available type with an active flow
first_type = equipment_types[:1]
equipment_type = first_type.code if first_type else 'manual_wheelchair'
else:
equipment_type = 'manual_wheelchair'
flow = Flow.search([
('equipment_type', '=', equipment_type),
('state', '=', 'active'),
], limit=1)
# Auto-create default steps for active flows that have none
if flow and not flow.step_ids:
try:
flow.action_create_default_steps()
except Exception:
_logger.warning("Failed to auto-create steps for flow %s", flow.id)
flow_steps = flow.step_ids.sorted('sequence') if flow else FlowStep.browse()
# If no flow found, try any active flow as fallback
if not flow_steps:
flow = Flow.search([('state', '=', 'active')], limit=1)
if flow:
if not flow.step_ids:
try:
flow.action_create_default_steps()
except Exception:
pass
flow_steps = flow.step_ids.sorted('sequence')
# Final fallback: SimpleNamespace objects matching step record interface
if not flow_steps:
flow_steps = [
SimpleNamespace(id=0, name='Client & Equipment', step_type='client_info',
icon='fa-user', section_code='', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Measurements', step_type='measurements',
icon='fa-ruler', section_code='', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Frame', step_type='product_select',
icon='fa-wheelchair', section_code='frame', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Seating', step_type='options',
icon='fa-chair', section_code='seating', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Options', step_type='options',
icon='fa-list', section_code='mw_adp_options,mw_accessories', fields_json='', help_text='', is_required=True),
SimpleNamespace(id=0, name='Review', step_type='review',
icon='fa-clipboard', section_code='', fields_json='', help_text='', is_required=True),
]
# Parse fields_json for dynamic measurement/custom steps
step_fields = {}
for step in flow_steps:
fj = step.fields_json if hasattr(step, 'fields_json') else ''
if fj:
try:
step_fields[step.id] = json.loads(fj)
except (ValueError, TypeError):
step_fields[step.id] = []
# Pre-build a dict of field values so QWeb can look them up by name
field_values = {}
if assessment:
# Standard wheelchair measurement fields
for fname in [
'seat_width', 'seat_depth', 'finished_seat_to_floor_height',
'back_cane_height', 'finished_back_height',
'finished_leg_rest_length', 'client_weight',
'seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
'client_weight_unit',
]:
field_values[fname] = assessment[fname] or ''
# Equipment-specific data from JSON (for non-wheelchair types)
if assessment.equipment_data_json:
try:
equipment_data = json.loads(assessment.equipment_data_json)
if isinstance(equipment_data, dict):
field_values.update(equipment_data)
except (ValueError, TypeError):
pass
return {
'assessment': assessment,
'sections': sections,
'countries': countries,
'provinces': provinces,
'field_values': field_values,
'equipment_types': equipment_types,
'equipment_type': equipment_type,
'flow': flow,
'flow_steps': flow_steps,
'step_fields': step_fields,
}
# =========================================================================
# NEW ASSESSMENT FORM
# =========================================================================
@http.route('/my/quotation/builder/new', type='http', auth='user', website=True)
def quotation_builder_new(self, **kw):
ctx = self._get_form_context(
assessment=None,
requested_equipment_type=kw.get('equipment_type'),
)
ctx['page_name'] = 'wc_assessment_new'
return request.render('fusion_quotations.portal_quotation_form', ctx)
# =========================================================================
# EDIT EXISTING ASSESSMENT
# =========================================================================
@http.route('/my/quotation/builder/<int:assessment_id>/edit',
type='http', auth='user', website=True)
def quotation_builder_edit(self, assessment_id, **kw):
Assessment = request.env['fusion.wc.assessment'].sudo()
assessment = Assessment.browse(assessment_id)
if not assessment.exists() or assessment.sales_rep_id.id != request.env.user.id:
return request.redirect('/my/quotation/builder')
ctx = self._get_form_context(assessment=assessment)
ctx['page_name'] = 'wc_assessment_edit'
return request.render('fusion_quotations.portal_quotation_form', ctx)
# =========================================================================
# SAVE ASSESSMENT (FINAL SUBMIT)
# =========================================================================
@http.route('/my/quotation/builder/save', type='http', auth='user',
website=True, methods=['POST'], csrf=True)
def quotation_builder_save(self, **post):
Assessment = request.env['fusion.wc.assessment'].sudo()
assessment_id = int(post.get('assessment_id') or 0)
vals = self._extract_assessment_vals(post)
if assessment_id:
assessment = Assessment.browse(assessment_id)
if assessment.exists() and assessment.sales_rep_id.id == request.env.user.id:
assessment.write(vals)
else:
vals['sales_rep_id'] = request.env.user.id
assessment = Assessment.create(vals)
# Process selected items (lines)
self._process_assessment_lines(assessment, post)
# Check if user wants to generate quotation
if post.get('action') == 'generate':
try:
assessment.action_generate_quotation()
return request.redirect(
f'/my/quotation/builder/{assessment.id}/edit?success=quotation_generated')
except Exception as e:
_logger.error("Quotation generation failed: %s", str(e))
return request.redirect(
f'/my/quotation/builder/{assessment.id}/edit?error={str(e)}')
return request.redirect(
f'/my/quotation/builder/{assessment.id}/edit?success=saved')
# Known POST keys that map to standard assessment fields (not dynamic)
_KNOWN_POST_KEYS = {
'csrf_token', 'assessment_id', 'current_step', 'action', 'access_token',
'line_product_ids', 'line_section_ids', 'line_build_types',
'line_quantities', 'line_rationales',
'partner_id', 'create_new_partner',
'client_first_name', 'client_last_name', 'client_phone', 'client_email',
'client_street', 'client_street2', 'client_city', 'client_state_id',
'client_zip', 'client_country_id', 'client_dob', 'client_health_card',
'equipment_type', 'wheelchair_type', 'powerchair_type',
'client_type', 'build_type', 'reason_for_application', 'authorizer_id',
'seat_width', 'seat_depth', 'finished_seat_to_floor_height',
'back_cane_height', 'finished_back_height', 'finished_leg_rest_length',
'client_weight',
'seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
'client_weight_unit',
'frame_product_tmpl_id', 'frame_product_id', 'frame_notes', 'notes',
}
def _extract_assessment_vals(self, post):
"""Extract assessment field values from POST data."""
vals = {}
# Client
partner_id = post.get('partner_id')
if partner_id and partner_id != '0':
vals['partner_id'] = int(partner_id)
vals['create_new_partner'] = False
else:
vals['create_new_partner'] = True
vals['client_first_name'] = post.get('client_first_name', '')
vals['client_last_name'] = post.get('client_last_name', '')
vals['client_phone'] = post.get('client_phone', '')
vals['client_email'] = post.get('client_email', '')
vals['client_street'] = post.get('client_street', '')
vals['client_street2'] = post.get('client_street2', '')
vals['client_city'] = post.get('client_city', '')
state_id = post.get('client_state_id')
if state_id:
vals['client_state_id'] = int(state_id)
vals['client_zip'] = post.get('client_zip', '')
country_id = post.get('client_country_id')
if country_id:
vals['client_country_id'] = int(country_id)
dob = post.get('client_dob')
if dob:
vals['client_dob'] = dob
vals['client_health_card'] = post.get('client_health_card', '')
# Equipment
vals['equipment_type'] = post.get('equipment_type', 'manual_wheelchair')
vals['wheelchair_type'] = post.get('wheelchair_type', '')
vals['powerchair_type'] = post.get('powerchair_type', '')
vals['client_type'] = post.get('client_type', 'reg')
vals['build_type'] = post.get('build_type', 'modular')
vals['reason_for_application'] = post.get('reason_for_application', '')
# Authorizer
authorizer_id = post.get('authorizer_id')
if authorizer_id:
vals['authorizer_id'] = int(authorizer_id)
# Measurements
for field in ['seat_width', 'seat_depth', 'finished_seat_to_floor_height',
'back_cane_height', 'finished_back_height',
'finished_leg_rest_length', 'client_weight']:
val = post.get(field)
if val:
try:
vals[field] = float(val)
except (ValueError, TypeError):
pass
# Measurement units
for field in ['seat_width_unit', 'seat_depth_unit', 'seat_to_floor_unit',
'cane_height_unit', 'back_height_unit', 'leg_rest_unit',
'client_weight_unit']:
val = post.get(field)
if val:
vals[field] = val
# Frame
frame_tmpl = post.get('frame_product_tmpl_id')
if frame_tmpl:
vals['frame_product_tmpl_id'] = int(frame_tmpl)
frame_product = post.get('frame_product_id')
if frame_product:
vals['frame_product_id'] = int(frame_product)
vals['frame_notes'] = post.get('frame_notes', '')
vals['notes'] = post.get('notes', '')
# Current step
step = post.get('current_step')
if step:
vals['current_step'] = int(step)
# ── Dynamic / equipment-specific fields → equipment_data_json ──
# Collect any POST keys not in the known set — these come from
# dynamic measurement / custom steps defined via fields_json
equipment_data = {}
for key, value in post.items():
if key not in self._KNOWN_POST_KEYS and not key.startswith('section_'):
# Skip empty values
if value:
equipment_data[key] = value
if equipment_data:
# Merge with existing equipment_data_json if editing
assessment_id = int(post.get('assessment_id') or 0)
if assessment_id:
assessment = request.env['fusion.wc.assessment'].sudo().browse(assessment_id)
if assessment.exists() and assessment.equipment_data_json:
try:
existing = json.loads(assessment.equipment_data_json)
if isinstance(existing, dict):
existing.update(equipment_data)
equipment_data = existing
except (ValueError, TypeError):
pass
vals['equipment_data_json'] = json.dumps(equipment_data)
return vals
def _process_assessment_lines(self, assessment, post):
"""Process selected products from the form into assessment lines."""
AssessmentLine = request.env['fusion.wc.assessment.line'].sudo()
# Remove existing non-upcharge lines (will be recreated from form)
assessment.line_ids.filtered(lambda l: not l.is_upcharge).unlink()
# Parse lines from POST data
# Format: line_product_ids = comma-separated product IDs
# line_sections = comma-separated section IDs (parallel array)
# line_build_types = comma-separated build types (parallel array)
line_products = post.get('line_product_ids', '').split(',')
line_sections = post.get('line_section_ids', '').split(',')
line_build_types = post.get('line_build_types', '').split(',')
line_quantities = post.get('line_quantities', '').split(',')
line_rationales = post.get('line_rationales', '').split('|||')
for idx, tmpl_id_str in enumerate(line_products):
if not tmpl_id_str.strip():
continue
try:
tmpl_id = int(tmpl_id_str.strip())
except (ValueError, TypeError):
continue
# Resolve product template → first active variant
tmpl = request.env['product.template'].sudo().browse(tmpl_id)
if not tmpl.exists():
continue
product = tmpl.product_variant_ids[:1]
if not product:
continue
section_id = False
if idx < len(line_sections) and line_sections[idx].strip():
try:
section_id = int(line_sections[idx].strip())
except (ValueError, TypeError):
pass
build_type = False
if idx < len(line_build_types) and line_build_types[idx].strip():
build_type = line_build_types[idx].strip()
quantity = 1.0
if idx < len(line_quantities) and line_quantities[idx].strip():
try:
quantity = float(line_quantities[idx].strip())
except (ValueError, TypeError):
pass
rationale = ''
if idx < len(line_rationales):
rationale = line_rationales[idx].strip()
tmpl = product.product_tmpl_id
AssessmentLine.create({
'assessment_id': assessment.id,
'section_id': section_id,
'product_id': product.id,
'product_name': product.display_name,
'quantity': quantity,
'adp_device_code': tmpl.x_fc_adp_device_code or '',
'adp_price': tmpl.x_fc_adp_price or 0.0,
'unit_price': product.lst_price,
'build_type': build_type if build_type in ('modular', 'custom_fabricated') else False,
'clinical_rationale': rationale,
})
class QuotationPublic(http.Controller):
"""Public (token-based) access to the assessment form — no login required."""
def _validate_token(self, token):
"""Look up and validate an assessment by its access token."""
assessment = request.env['fusion.wc.assessment'].sudo().search([
('access_token', '=', token),
], limit=1)
if not assessment:
return None, 'not_found'
if assessment.state == 'cancelled':
return assessment, 'cancelled'
return assessment, 'ok'
# ------------------------------------------------------------------
# Public form — full portal chrome
# ------------------------------------------------------------------
@http.route('/quotation/form/<string:token>', type='http',
auth='public', website=True, sitemap=False)
def public_form_view(self, token, **kw):
"""Render the assessment form for public (unauthenticated) users."""
assessment, status = self._validate_token(token)
if status != 'ok':
return request.render('fusion_quotations.public_form_invalid', {
'status': status,
})
portal_ctrl = QuotationPortal()
ctx = portal_ctrl._get_form_context(
assessment=assessment,
requested_equipment_type=kw.get('equipment_type'),
)
ctx['page_name'] = 'wc_assessment_public'
ctx['is_public'] = True
ctx['access_token'] = token
return request.render('fusion_quotations.portal_quotation_form', ctx)
# ------------------------------------------------------------------
# Embeddable form — no portal chrome, iframe-friendly
# ------------------------------------------------------------------
@http.route('/quotation/form/<string:token>/embed', type='http',
auth='public', website=True, sitemap=False)
def embed_form_view(self, token, **kw):
"""Render a minimal, iframe-embeddable version of the form."""
assessment, status = self._validate_token(token)
if status != 'ok':
return request.render('fusion_quotations.public_form_invalid', {
'status': status,
})
portal_ctrl = QuotationPortal()
ctx = portal_ctrl._get_form_context(assessment=assessment)
ctx['page_name'] = 'wc_assessment_embed'
ctx['is_public'] = True
ctx['is_embed'] = True
ctx['access_token'] = token
resp = request.render('fusion_quotations.portal_quotation_form_embed', ctx)
resp.headers['X-Frame-Options'] = 'ALLOWALL'
resp.headers['Content-Security-Policy'] = ''
return resp
# ------------------------------------------------------------------
# Public form save (POST)
# ------------------------------------------------------------------
@http.route('/quotation/form/<string:token>/save', type='http',
auth='public', website=True, methods=['POST'],
csrf=True, sitemap=False)
def public_form_save(self, token, **post):
"""Save form data submitted from the public/embed form."""
assessment, status = self._validate_token(token)
if status != 'ok' or not assessment:
return request.redirect('/')
if assessment.state not in ('draft', 'review'):
return request.redirect(f'/quotation/form/{token}?error=readonly')
portal_ctrl = QuotationPortal()
vals = portal_ctrl._extract_assessment_vals(post)
assessment.sudo().write(vals)
portal_ctrl._process_assessment_lines(assessment, post)
if post.get('action') == 'generate':
try:
assessment.sudo().action_generate_quotation()
return request.redirect(
f'/quotation/form/{token}?success=quotation_generated')
except Exception as e:
_logger.error("Public quotation generation failed: %s", str(e))
return request.redirect(
f'/quotation/form/{token}?error={str(e)}')
return request.redirect(f'/quotation/form/{token}?success=saved')

View File

@@ -0,0 +1,388 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
import json
import logging
_logger = logging.getLogger(__name__)
class QuotationAPI(http.Controller):
@http.route('/my/quotation/api/search_clients', type='json',
auth='user', methods=['POST'])
def search_clients(self, query='', limit=20, **kw):
"""Search existing clients by name, phone, or health card."""
if not query or len(query) < 2:
return []
Partner = request.env['res.partner'].sudo()
domain = [
'|',
('name', 'ilike', query),
('phone', 'ilike', query),
]
partners = Partner.search(domain, limit=limit, order='name')
return [{
'id': p.id,
'name': p.name,
'phone': p.phone or '',
'email': p.email or '',
'street': p.street or '',
'city': p.city or '',
} for p in partners]
@http.route('/my/quotation/api/search_products', type='json',
auth='user', methods=['POST'])
def search_products(self, query='', section_code=None, limit=20, **kw):
"""Search product templates (parent products), optionally filtered by section.
Returns templates with variant_count so the UI can show a configurator
when multiple variants exist.
"""
if not query or len(query) < 2:
return []
Template = request.env['product.template'].sudo()
domain = [
('sale_ok', '=', True),
'|', '|',
('name', 'ilike', query),
('default_code', 'ilike', query),
('x_fc_adp_device_code', 'ilike', query),
]
# If section specified, filter by category
if section_code:
Section = request.env['fusion.wc.section'].sudo()
section = Section.search([('code', '=', section_code)], limit=1)
if section and section.product_category_id:
domain.append(('categ_id', 'child_of', section.product_category_id.id))
templates = Template.search(domain, limit=limit, order='name')
results = []
for t in templates:
variant_count = len(t.product_variant_ids)
has_configurable = bool(
t.attribute_line_ids and variant_count > 1
)
# For single-variant templates, get the variant ID directly
single_variant_id = (
t.product_variant_ids[0].id
if variant_count == 1 else False
)
results.append({
'id': t.id,
'name': t.name,
'default_code': t.default_code or '',
'adp_device_code': t.x_fc_adp_device_code or '',
'adp_price': t.x_fc_adp_price or 0.0,
'list_price': t.list_price,
'variant_count': variant_count,
'has_configurable_attributes': has_configurable,
'single_variant_id': single_variant_id,
})
return results
@http.route('/my/quotation/api/get_product_attributes', type='json',
auth='user', methods=['POST'])
def get_product_attributes(self, template_id=None, **kw):
"""Given a product template ID, return its configurable attributes
with available values for each.
"""
if not template_id:
return []
Template = request.env['product.template'].sudo()
tmpl = Template.browse(int(template_id))
if not tmpl.exists():
return []
attributes = []
for line in tmpl.attribute_line_ids:
values = []
for val in line.value_ids:
values.append({
'id': val.id,
'name': val.name,
})
attributes.append({
'attribute_id': line.attribute_id.id,
'attribute_name': line.attribute_id.name,
'values': values,
})
return attributes
@http.route('/my/quotation/api/resolve_variant', type='json',
auth='user', methods=['POST'])
def resolve_variant(self, template_id=None, attribute_value_ids=None, **kw):
"""Given a template ID and list of selected attribute value IDs,
find and return the matching product.product variant.
"""
if not template_id or not attribute_value_ids:
return {'error': 'Missing template_id or attribute_value_ids'}
Template = request.env['product.template'].sudo()
tmpl = Template.browse(int(template_id))
if not tmpl.exists():
return {'error': 'Template not found'}
# Convert to set for matching
selected_set = set(int(v) for v in attribute_value_ids)
# Search through variants to find the one matching all selected values
for variant in tmpl.product_variant_ids:
variant_values = set()
for ptav in variant.product_template_attribute_value_ids:
variant_values.add(ptav.product_attribute_value_id.id)
if variant_values == selected_set:
return {
'id': variant.id,
'name': variant.display_name,
'default_code': variant.default_code or '',
'list_price': variant.lst_price,
}
# No exact match found — return first variant as fallback
fallback = tmpl.product_variant_ids[:1]
if fallback:
return {
'id': fallback.id,
'name': fallback.display_name,
'default_code': fallback.default_code or '',
'list_price': fallback.lst_price,
'warning': 'No exact variant match found, using default variant.',
}
return {'error': 'No variants found for this template'}
@http.route('/my/quotation/api/get_section_options', type='json',
auth='user', methods=['POST'])
def get_section_options(self, section_code=None, build_type=None, **kw):
"""Get standard options for a section."""
if not section_code:
return []
Section = request.env['fusion.wc.section'].sudo()
section = Section.search([('code', '=', section_code)], limit=1)
if not section:
return []
domain = [('section_id', '=', section.id), ('is_standard', '=', True)]
if build_type and build_type in ('modular', 'custom_fabricated'):
domain.append(('available_build_types', 'in', [build_type, 'both']))
options = request.env['fusion.wc.section.option'].sudo().search(
domain, order='sequence')
return [{
'id': o.id,
'product_tmpl_id': o.product_tmpl_id.id,
'product_name': o.product_tmpl_id.display_name,
'variant_count': o.variant_count,
'adp_device_code': o.adp_device_code or '',
'adp_price': o.adp_price or 0.0,
'list_price': o.list_price or 0.0,
'requires_clinical_rationale': o.requires_clinical_rationale,
'available_build_types': o.available_build_types,
} for o in options]
@http.route('/my/quotation/api/check_upcharges', type='json',
auth='user', methods=['POST'])
def check_upcharges(self, assessment_data=None, **kw):
"""Check which upcharge rules would trigger for given measurements.
Returns list of upcharges WITHOUT creating any records.
"""
if not assessment_data:
return []
rules = request.env['fusion.wc.upcharge.rule'].sudo().search([
('active', '=', True),
])
equipment_type = assessment_data.get('equipment_type', 'manual_wheelchair')
triggered = []
exclusive_triggered = {}
# Normalize measurements
def get_value(field, unit_field, default_unit='inches'):
val = float(assessment_data.get(field, 0) or 0)
unit = assessment_data.get(unit_field, default_unit)
if unit == 'cm' and val:
val = val / 2.54
return val
seat_width = get_value('seat_width', 'seat_width_unit')
seat_depth = get_value('seat_depth', 'seat_depth_unit')
back_height = get_value('finished_back_height', 'back_height_unit')
client_weight = float(assessment_data.get('client_weight', 0) or 0)
weight_unit = assessment_data.get('client_weight_unit', 'lbs')
if weight_unit == 'kg' and client_weight:
client_weight = client_weight * 2.20462
# Get backrest width from lines or default to seat_width
back_width = float(assessment_data.get('back_width', 0) or 0)
if not back_width:
back_width = seat_width
measurement_map = {
'seat_width': seat_width,
'seat_depth': seat_depth,
'back_width': back_width,
'back_height': back_height,
}
for rule in rules.sorted('sequence'):
if rule.equipment_type not in (equipment_type, 'both'):
continue
if rule.mutually_exclusive_group:
if rule.mutually_exclusive_group in exclusive_triggered:
continue
matched = False
if rule.trigger_type == 'measurement':
val = measurement_map.get(rule.measurement_field, 0)
if val:
matched = self._compare(val, rule.comparison, rule.threshold_value)
elif rule.trigger_type == 'weight':
if client_weight > rule.weight_min:
if not rule.weight_max or client_weight <= rule.weight_max:
matched = True
elif rule.trigger_type == 'dimension_mismatch':
val1 = measurement_map.get(rule.compare_field_1, 0)
val2 = measurement_map.get(rule.compare_field_2, 0)
if val1 and val2 and abs(val1 - val2) > 0.01:
matched = True
if matched:
# Look up ADP price
adp_device = request.env['fusion.adp.device.code'].sudo().search(
[('device_code', '=', rule.adp_device_code), ('active', '=', True)],
limit=1
)
triggered.append({
'rule_id': rule.id,
'name': rule.name,
'adp_device_code': rule.adp_device_code,
'adp_price': adp_device.adp_price if adp_device else 0,
'description': rule.description or '',
'measurement_field': rule.measurement_field or '',
})
if rule.mutually_exclusive_group:
exclusive_triggered[rule.mutually_exclusive_group] = True
return triggered
@http.route('/my/quotation/api/save_step', type='json',
auth='user', methods=['POST'])
def save_step(self, assessment_id=None, step=None, data=None, **kw):
"""Auto-save current step data as JSON for resume."""
if not assessment_id:
return {'success': False, 'error': 'No assessment ID'}
Assessment = request.env['fusion.wc.assessment'].sudo()
assessment = Assessment.browse(int(assessment_id))
if not assessment.exists() or assessment.sales_rep_id.id != request.env.user.id:
return {'success': False, 'error': 'Access denied'}
vals = {'current_step': int(step) if step else 1}
if data:
vals['form_data_json'] = json.dumps(data)
assessment.write(vals)
return {'success': True}
@staticmethod
def _compare(value, comparison, 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
class QuotationPublicAPI(http.Controller):
"""Public (token-based) JSON API — mirrors QuotationAPI for unauthenticated access."""
def _validate_token(self, token):
assessment = request.env['fusion.wc.assessment'].sudo().search([
('access_token', '=', token),
], limit=1)
return assessment if assessment else None
# --- delegates ---------------------------------------------------------
# Each route validates the token then forwards to the existing API class.
_api = QuotationAPI()
@http.route('/quotation/api/<string:token>/search_clients',
type='json', auth='public', methods=['POST'])
def public_search_clients(self, token, **kw):
if not self._validate_token(token):
return {'error': 'invalid_token'}
return self._api.search_clients(**kw)
@http.route('/quotation/api/<string:token>/search_products',
type='json', auth='public', methods=['POST'])
def public_search_products(self, token, **kw):
if not self._validate_token(token):
return {'error': 'invalid_token'}
return self._api.search_products(**kw)
@http.route('/quotation/api/<string:token>/get_product_attributes',
type='json', auth='public', methods=['POST'])
def public_get_product_attributes(self, token, **kw):
if not self._validate_token(token):
return {'error': 'invalid_token'}
return self._api.get_product_attributes(**kw)
@http.route('/quotation/api/<string:token>/resolve_variant',
type='json', auth='public', methods=['POST'])
def public_resolve_variant(self, token, **kw):
if not self._validate_token(token):
return {'error': 'invalid_token'}
return self._api.resolve_variant(**kw)
@http.route('/quotation/api/<string:token>/get_section_options',
type='json', auth='public', methods=['POST'])
def public_get_section_options(self, token, **kw):
if not self._validate_token(token):
return {'error': 'invalid_token'}
return self._api.get_section_options(**kw)
@http.route('/quotation/api/<string:token>/check_upcharges',
type='json', auth='public', methods=['POST'])
def public_check_upcharges(self, token, **kw):
if not self._validate_token(token):
return {'error': 'invalid_token'}
return self._api.check_upcharges(**kw)
@http.route('/quotation/api/<string:token>/save_step',
type='json', auth='public', methods=['POST'])
def public_save_step(self, token, **kw):
assessment = self._validate_token(token)
if not assessment:
return {'error': 'invalid_token'}
# Override assessment_id from the token-linked assessment
kw['assessment_id'] = assessment.id
# Bypass user ownership check by handling directly
vals = {'current_step': int(kw.get('step', 1))}
if kw.get('data'):
vals['form_data_json'] = json.dumps(kw['data'])
assessment.sudo().write(vals)
return {'success': True}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================
Equipment Type Registry — Seed Data
============================================================ -->
<record id="equipment_type_manual_wheelchair" model="fusion.equipment.type">
<field name="code">manual_wheelchair</field>
<field name="name">Manual Wheelchair</field>
<field name="icon">fa-wheelchair</field>
<field name="sequence">10</field>
</record>
<record id="equipment_type_power_wheelchair" model="fusion.equipment.type">
<field name="code">power_wheelchair</field>
<field name="name">Power Wheelchair / Scooter</field>
<field name="icon">fa-bolt</field>
<field name="sequence">20</field>
</record>
<record id="equipment_type_walker" model="fusion.equipment.type">
<field name="code">walker</field>
<field name="name">Walker / Rollator / Ambulation Aid</field>
<field name="icon">fa-male</field>
<field name="sequence">30</field>
</record>
<record id="equipment_type_stair_lift" model="fusion.equipment.type">
<field name="code">stair_lift</field>
<field name="name">Stair Lift</field>
<field name="icon">fa-arrow-up</field>
<field name="sequence">40</field>
</record>
<record id="equipment_type_porch_lift" model="fusion.equipment.type">
<field name="code">porch_lift</field>
<field name="name">Porch Lift</field>
<field name="icon">fa-building</field>
<field name="sequence">50</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,414 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- ============================================================ -->
<!-- SECTION 2b: MANUAL WHEELCHAIR -->
<!-- ============================================================ -->
<record id="section_mw_frame" model="fusion.wc.section">
<field name="name">Manual Wheelchair Frame</field>
<field name="code">mw_frame</field>
<field name="sequence">10</field>
<field name="equipment_type">manual_wheelchair</field>
<field name="icon">fa-wheelchair</field>
<field name="description">Select the manual wheelchair frame. Includes standard, lightweight, tilt, and rigid frames.</field>
<field name="allow_multiple" eval="False"/>
<field name="required" eval="True"/>
</record>
<record id="section_mw_adp_options" model="fusion.wc.section">
<field name="name">MW Additional ADP Funded Options</field>
<field name="code">mw_adp_options</field>
<field name="sequence">15</field>
<field name="equipment_type">manual_wheelchair</field>
<field name="icon">fa-check-square-o</field>
<field name="description">ADP upcharge/modification codes for manual wheelchairs (WAMA, WAMB, heavy duty, custom mods).</field>
<field name="is_adp_options_section" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_mw_accessories" model="fusion.wc.section">
<field name="name">MW Accessories</field>
<field name="code">mw_accessories</field>
<field name="sequence">18</field>
<field name="equipment_type">manual_wheelchair</field>
<field name="icon">fa-cogs</field>
<field name="description">Manual wheelchair accessories: handrims, spoke protectors, grade aids, etc.</field>
<field name="allow_multiple" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- SECTION 2a: WALKER / ROLLATOR / AMBULATION AIDS -->
<!-- ============================================================ -->
<record id="section_walker_frame" model="fusion.wc.section">
<field name="name">Walker / Rollator Frame</field>
<field name="code">walker_frame</field>
<field name="sequence">200</field>
<field name="equipment_type">walker</field>
<field name="icon">fa-male</field>
<field name="description">Select the walker, rollator, walking frame, standing frame, or forearm crutches.</field>
<field name="allow_multiple" eval="False"/>
<field name="required" eval="True"/>
</record>
<record id="section_walker_adp_options" model="fusion.wc.section">
<field name="name">Walker ADP Funded Options</field>
<field name="code">walker_adp_options</field>
<field name="sequence">210</field>
<field name="equipment_type">walker</field>
<field name="icon">fa-check-square-o</field>
<field name="description">Adolescent size upgrades and custom modifications for walkers.</field>
<field name="is_adp_options_section" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_walker_accessories" model="fusion.wc.section">
<field name="name">Walker Accessories / Addons</field>
<field name="code">walker_accessories</field>
<field name="sequence">220</field>
<field name="equipment_type">walker</field>
<field name="icon">fa-cogs</field>
<field name="description">Walker addons and accessories.</field>
<field name="allow_multiple" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- SECTION 2c: POWER BASE / POWER SCOOTER -->
<!-- ============================================================ -->
<record id="section_pw_frame" model="fusion.wc.section">
<field name="name">Power Base / Scooter Frame</field>
<field name="code">pw_frame</field>
<field name="sequence">300</field>
<field name="equipment_type">power_wheelchair</field>
<field name="icon">fa-bolt</field>
<field name="description">Select the power wheelchair base or power scooter.</field>
<field name="allow_multiple" eval="False"/>
<field name="required" eval="True"/>
</record>
<record id="section_pw_adp_options" model="fusion.wc.section">
<field name="name">Power Base ADP Funded Options</field>
<field name="code">pw_adp_options</field>
<field name="sequence">310</field>
<field name="equipment_type">power_wheelchair</field>
<field name="icon">fa-check-square-o</field>
<field name="description">ADP funded options for power bases: adjustable tension back, recline, footplates, seat packages, etc.</field>
<field name="is_adp_options_section" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_pw_specialty_controls" model="fusion.wc.section">
<field name="name">Specialty Controls</field>
<field name="code">pw_specialty_controls</field>
<field name="sequence">320</field>
<field name="equipment_type">power_wheelchair</field>
<field name="icon">fa-gamepad</field>
<field name="description">Specialty controls requiring clinical rationale: non-standard joystick, chin/rim, simple touch, proximity, breath control, scanners, auto correction.</field>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_pw_positioning" model="fusion.wc.section">
<field name="name">Power Positioning Devices</field>
<field name="code">pw_positioning</field>
<field name="sequence">330</field>
<field name="equipment_type">power_wheelchair</field>
<field name="icon">fa-sliders</field>
<field name="description">Power positioning devices requiring Justification for Funding Chart: power tilt, recline, elevating footrests, multi-function control, power add-on.</field>
<field name="allow_multiple" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- SEATING SECTIONS (Section 2d - shared across MW/PW) -->
<!-- ============================================================ -->
<record id="section_seat_cushion" model="fusion.wc.section">
<field name="name">Seat Cushion</field>
<field name="code">seat_cushion</field>
<field name="sequence">30</field>
<field name="equipment_type">both</field>
<field name="icon">fa-th-large</field>
<field name="has_build_type" eval="True"/>
<field name="has_width" eval="True"/>
<field name="has_depth" eval="True"/>
<field name="width_label">Cushion Width (inches)</field>
<field name="depth_label">Cushion Depth (inches)</field>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_seat_cushion_cover" model="fusion.wc.section">
<field name="name">Seat Cushion Cover(s)</field>
<field name="code">seat_cushion_cover</field>
<field name="sequence">31</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_seat_cushion"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_seat_options" model="fusion.wc.section">
<field name="name">Seat Option(s)</field>
<field name="code">seat_options</field>
<field name="sequence">32</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_seat_cushion"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_seat_hardware" model="fusion.wc.section">
<field name="name">Seat Hardware</field>
<field name="code">seat_hardware</field>
<field name="sequence">33</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_seat_cushion"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_pommel" model="fusion.wc.section">
<field name="name">Pommel/Adductors</field>
<field name="code">pommel</field>
<field name="sequence">34</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_seat_cushion"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_pommel_hardware" model="fusion.wc.section">
<field name="name">Pommel Hardware</field>
<field name="code">pommel_hardware</field>
<field name="sequence">35</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_seat_cushion"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Back Support -->
<record id="section_back_support" model="fusion.wc.section">
<field name="name">Back Support</field>
<field name="code">back_support</field>
<field name="sequence">40</field>
<field name="equipment_type">both</field>
<field name="icon">fa-columns</field>
<field name="has_build_type" eval="True"/>
<field name="has_width" eval="True"/>
<field name="has_height" eval="True"/>
<field name="width_label">Backrest Width (inches)</field>
<field name="height_label">Backrest Height (inches)</field>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_back_support_options" model="fusion.wc.section">
<field name="name">Back Support Options</field>
<field name="code">back_support_options</field>
<field name="sequence">41</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_back_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_back_cover" model="fusion.wc.section">
<field name="name">Back Cover</field>
<field name="code">back_cover</field>
<field name="sequence">42</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_back_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_back_hardware" model="fusion.wc.section">
<field name="name">Back Hardware</field>
<field name="code">back_hardware</field>
<field name="sequence">43</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_back_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Complete Assembly -->
<record id="section_complete_assembly" model="fusion.wc.section">
<field name="name">Complete Assembly</field>
<field name="code">complete_assembly</field>
<field name="sequence">50</field>
<field name="equipment_type">both</field>
<field name="icon">fa-cubes</field>
<field name="has_build_type" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Headrest/Neckrest -->
<record id="section_headrest" model="fusion.wc.section">
<field name="name">Headrest/Neckrest</field>
<field name="code">headrest</field>
<field name="sequence">60</field>
<field name="equipment_type">both</field>
<field name="icon">fa-user</field>
<field name="has_build_type" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_headrest_options" model="fusion.wc.section">
<field name="name">Headrest/Neckrest Options</field>
<field name="code">headrest_options</field>
<field name="sequence">61</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_headrest"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_headrest_hardware" model="fusion.wc.section">
<field name="name">Headrest/Neckrest Hardware</field>
<field name="code">headrest_hardware</field>
<field name="sequence">62</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_headrest"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Positioning Belts -->
<record id="section_positioning_belts" model="fusion.wc.section">
<field name="name">Positioning Belts</field>
<field name="code">positioning_belts</field>
<field name="sequence">70</field>
<field name="equipment_type">both</field>
<field name="icon">fa-link</field>
<field name="has_build_type" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_positioning_belt_options" model="fusion.wc.section">
<field name="name">Positioning Belt Options</field>
<field name="code">positioning_belt_options</field>
<field name="sequence">71</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_positioning_belts"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Arm Support -->
<record id="section_arm_support" model="fusion.wc.section">
<field name="name">Arm Support(s)</field>
<field name="code">arm_support</field>
<field name="sequence">80</field>
<field name="equipment_type">both</field>
<field name="icon">fa-hand-o-right</field>
<field name="has_build_type" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_arm_support_options" model="fusion.wc.section">
<field name="name">Arm Support Options</field>
<field name="code">arm_support_options</field>
<field name="sequence">81</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_arm_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_arm_support_hardware" model="fusion.wc.section">
<field name="name">Arm Support Hardware</field>
<field name="code">arm_support_hardware</field>
<field name="sequence">82</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_arm_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Tray -->
<record id="section_tray" model="fusion.wc.section">
<field name="name">Tray</field>
<field name="code">tray</field>
<field name="sequence">90</field>
<field name="equipment_type">both</field>
<field name="icon">fa-inbox</field>
<field name="has_build_type" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_tray_options" model="fusion.wc.section">
<field name="name">Tray Options</field>
<field name="code">tray_options</field>
<field name="sequence">91</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_tray"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Lateral Support -->
<record id="section_lateral_support" model="fusion.wc.section">
<field name="name">Lateral Support(s)</field>
<field name="code">lateral_support</field>
<field name="sequence">100</field>
<field name="equipment_type">both</field>
<field name="icon">fa-arrows-h</field>
<field name="has_build_type" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_lateral_support_options" model="fusion.wc.section">
<field name="name">Lateral Support Options</field>
<field name="code">lateral_support_options</field>
<field name="sequence">101</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_lateral_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_lateral_support_hardware" model="fusion.wc.section">
<field name="name">Lateral Support Hardware</field>
<field name="code">lateral_support_hardware</field>
<field name="sequence">102</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_lateral_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<!-- Foot/Leg Support -->
<record id="section_foot_leg_support" model="fusion.wc.section">
<field name="name">Foot/Leg Support(s)</field>
<field name="code">foot_leg_support</field>
<field name="sequence">110</field>
<field name="equipment_type">both</field>
<field name="icon">fa-arrows-v</field>
<field name="has_build_type" eval="True"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_foot_leg_support_options" model="fusion.wc.section">
<field name="name">Foot/Leg Support Options</field>
<field name="code">foot_leg_support_options</field>
<field name="sequence">111</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_foot_leg_support"/>
<field name="allow_multiple" eval="True"/>
</record>
<record id="section_foot_leg_support_hardware" model="fusion.wc.section">
<field name="name">Foot/Leg Support Hardware</field>
<field name="code">foot_leg_support_hardware</field>
<field name="sequence">112</field>
<field name="equipment_type">both</field>
<field name="has_build_type" eval="True"/>
<field name="parent_id" ref="section_foot_leg_support"/>
<field name="allow_multiple" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- WAMA: Seat width > 18 inches -->
<record id="upcharge_wama" model="fusion.wc.upcharge.rule">
<field name="name">Width Modification (WAMA)</field>
<field name="sequence">10</field>
<field name="trigger_type">measurement</field>
<field name="measurement_field">seat_width</field>
<field name="comparison">gt</field>
<field name="threshold_value">18.0</field>
<field name="adp_device_code">WAMA</field>
<field name="equipment_type">manual_wheelchair</field>
<field name="description">Seat width exceeds 18 inches — width modification upcharge applies.</field>
</record>
<!-- WAMB: Seat depth > 18 inches -->
<record id="upcharge_wamb" model="fusion.wc.upcharge.rule">
<field name="name">Depth Modification (WAMB)</field>
<field name="sequence">20</field>
<field name="trigger_type">measurement</field>
<field name="measurement_field">seat_depth</field>
<field name="comparison">gt</field>
<field name="threshold_value">18.0</field>
<field name="adp_device_code">WAMB</field>
<field name="equipment_type">manual_wheelchair</field>
<field name="description">Seat depth exceeds 18 inches — depth modification upcharge applies.</field>
</record>
<!-- WAMF: Client weight > 250 lbs (up to 350) -->
<record id="upcharge_wamf" model="fusion.wc.upcharge.rule">
<field name="name">Heavy Duty 250+ lbs (WAMF)</field>
<field name="sequence">30</field>
<field name="trigger_type">weight</field>
<field name="weight_min">250.0</field>
<field name="weight_max">350.0</field>
<field name="adp_device_code">WAMF</field>
<field name="equipment_type">both</field>
<field name="mutually_exclusive_group">weight</field>
<field name="description">Client weight exceeds 250 lbs — heavy duty frame upcharge.</field>
</record>
<!-- WAMG: Client weight > 350 lbs (up to 400) -->
<record id="upcharge_wamg" model="fusion.wc.upcharge.rule">
<field name="name">Extra Heavy Duty 350+ lbs (WAMG)</field>
<field name="sequence">31</field>
<field name="trigger_type">weight</field>
<field name="weight_min">350.0</field>
<field name="weight_max">400.0</field>
<field name="adp_device_code">WAMG</field>
<field name="equipment_type">both</field>
<field name="mutually_exclusive_group">weight</field>
<field name="description">Client weight exceeds 350 lbs — extra heavy duty frame upcharge.</field>
</record>
<!-- WAMH: Client weight > 400 lbs -->
<record id="upcharge_wamh" model="fusion.wc.upcharge.rule">
<field name="name">Bariatric 400+ lbs (WAMH)</field>
<field name="sequence">32</field>
<field name="trigger_type">weight</field>
<field name="weight_min">400.0</field>
<field name="weight_max">0</field>
<field name="adp_device_code">WAMH</field>
<field name="equipment_type">both</field>
<field name="mutually_exclusive_group">weight</field>
<field name="description">Client weight exceeds 400 lbs — bariatric frame upcharge.</field>
</record>
<!-- SEICF160L: Backrest width differs from seat width -->
<record id="upcharge_seicf160l" model="fusion.wc.upcharge.rule">
<field name="name">Different Backrest Width (SEICF160L)</field>
<field name="sequence">40</field>
<field name="trigger_type">dimension_mismatch</field>
<field name="compare_field_1">seat_width</field>
<field name="compare_field_2">back_width</field>
<field name="adp_device_code">SEICF160L</field>
<field name="equipment_type">both</field>
<field name="description">Backrest width differs from seat width — adapter hardware required.</field>
</record>
</data>
</odoo>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,849 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from markupsafe import Markup
import json
import uuid
import logging
_logger = logging.getLogger(__name__)
class WheelchairAssessment(models.Model):
_name = 'fusion.wc.assessment'
_description = 'Wheelchair Assessment & Quotation Builder'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
_rec_name = 'display_name'
# =========================================================================
# STATE & IDENTITY
# =========================================================================
state = fields.Selection([
('draft', 'In Progress'),
('review', 'Ready for Review'),
('quoted', 'Quotation Generated'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', tracking=True, index=True)
display_name = fields.Char(
string='Display Name',
compute='_compute_display_name',
store=True,
)
reference = fields.Char(
string='Reference',
readonly=True,
copy=False,
default=lambda self: _('New'),
)
# =========================================================================
# STEP 1: CLIENT & EQUIPMENT
# =========================================================================
partner_id = fields.Many2one('res.partner', string='Client',
tracking=True, index=True)
create_new_partner = fields.Boolean(string='New Client', default=False)
# Client info (for new clients or override)
client_first_name = fields.Char(string='First Name')
client_last_name = fields.Char(string='Last Name')
client_name = fields.Char(string='Client Name',
compute='_compute_client_name', store=True)
client_phone = fields.Char(string='Phone')
client_email = fields.Char(string='Email')
client_street = fields.Char(string='Street')
client_street2 = fields.Char(string='Unit/Apt')
client_city = fields.Char(string='City')
client_state_id = fields.Many2one('res.country.state', string='Province')
client_zip = fields.Char(string='Postal Code')
client_country_id = fields.Many2one('res.country', string='Country')
client_dob = fields.Date(string='Date of Birth')
client_health_card = fields.Char(string='Health Card Number')
equipment_type = fields.Selection(
selection='_get_equipment_type_selection',
string='Equipment Type', required=True, tracking=True, index=True)
@api.model
def _get_equipment_type_selection(self):
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
if types:
return [(t.code, t.name) for t in types]
return [
('manual_wheelchair', 'Manual Wheelchair'),
('power_wheelchair', 'Power Wheelchair / Scooter'),
('walker', 'Walker / Rollator / Ambulation Aid'),
]
wheelchair_type = fields.Selection([
('type_1', 'Type 1 - Standard'),
('type_2', 'Type 2 - Lightweight'),
('type_3', 'Type 3 - Ultra Lightweight'),
('type_4', 'Type 4 - Rigid Frame'),
('type_5', 'Type 5 - Dynamic Tilt'),
], string='Wheelchair Category')
powerchair_type = fields.Selection([
('type_1', 'Power Base Type 1'),
('type_2', 'Power Base Type 2'),
('type_3', 'Power Base Type 3'),
('scooter', 'Power Scooter'),
], string='Power Device Category')
walker_type = fields.Selection([
('adult_walker_1', 'Adult Wheeled Walker Type 1'),
('adult_walker_2', 'Adult Wheeled Walker Type 2'),
('adult_walker_3', 'Adult Wheeled Walker Type 3'),
('paed_walker_1', 'Paediatric Specific Wheeled Walker Type 1'),
('paed_walker_2', 'Paediatric Specific Wheeled Walker Type 2'),
('paed_walking_frame', 'Paediatric Specific Walking Frame'),
('paed_standing_1', 'Paediatric Standing Frame Type 1'),
('paed_standing_2', 'Paediatric Standing Frame Type 2'),
('forearm_crutches', 'Forearm Crutches'),
('stroller', 'Paediatric Specific Specialty Stroller'),
], string='Walker / Aid Category')
client_type = fields.Selection([
('reg', 'REG - Regular ADP (75/25)'),
('ods', 'ODS - ODSP (100% ADP)'),
('acs', 'ACS - ACSD (100% ADP)'),
('owp', 'OWP - Ontario Works (100% ADP)'),
('ltc', 'LTC - Long Term Care'),
('sen', 'SEN - Senior'),
('cca', 'CCA - Community Care'),
], string='Client Type', default='reg')
build_type = fields.Selection([
('modular', 'Modular'),
('custom_fabricated', 'Custom Fabricated'),
], string='Build Type', default='modular')
reason_for_application = fields.Selection([
('first_access', 'First Access'),
('additions', 'Additions'),
('modifications', 'Modifications'),
('replacements', 'Replacements'),
], string='Reason for Application')
# Assessment context
sales_rep_id = fields.Many2one('res.users', string='Sales Rep',
default=lambda self: self.env.user, tracking=True, index=True)
authorizer_id = fields.Many2one('res.partner', string='Authorizer/OT',
tracking=True)
assessment_date = fields.Datetime(string='Assessment Date',
default=fields.Datetime.now)
# =========================================================================
# STEP 2: ADP PRESCRIPTION MEASUREMENTS
# =========================================================================
seat_width = fields.Float(string='Seat Width', digits=(10, 2))
seat_width_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Seat Width Unit', default='inches')
seat_depth = fields.Float(string='Seat Depth', digits=(10, 2))
seat_depth_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Seat Depth Unit', default='inches')
finished_seat_to_floor_height = fields.Float(
string='Finished Seat to Floor Height', digits=(10, 2))
seat_to_floor_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Seat to Floor Unit', default='inches')
back_cane_height = fields.Float(string='Back Cane Height', digits=(10, 2))
cane_height_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Cane Height Unit', default='inches')
finished_back_height = fields.Float(
string='Finished Back Height', digits=(10, 2))
back_height_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Back Height Unit', default='inches')
finished_leg_rest_length = fields.Float(
string='Finished Leg Rest Length', digits=(10, 2))
leg_rest_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Leg Rest Unit', default='inches')
client_weight = fields.Float(string='Client Weight', digits=(10, 1))
client_weight_unit = fields.Selection([
('lbs', 'lbs'), ('kg', 'kg')
], string='Weight Unit', default='lbs')
# =========================================================================
# WALKER / AMBULATION AID PRESCRIPTION (Section 2a)
# =========================================================================
walker_seat_height = fields.Float(string='Seat Height', digits=(10, 2))
walker_seat_height_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm'), ('na', 'N/A')
], string='Seat Height Unit', default='inches')
push_handle_height = fields.Float(string='Push Handle Height', digits=(10, 2))
push_handle_height_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Push Handle Height Unit', default='inches')
hand_grips = fields.Selection([
('none', 'None'), ('standard', 'Standard'), ('anatomical', 'Anatomical'),
], string='Hand Grips')
forearm_attachments = fields.Selection([
('one', 'One'), ('two', 'Two'),
], string='Forearm Attachments')
width_between_push_handles = fields.Float(
string='Width Between Push Handles', digits=(10, 2))
push_handle_width_unit = fields.Selection([
('inches', 'Inches'), ('cm', 'cm')
], string='Push Handle Width Unit', default='inches')
walker_brakes = fields.Selection([
('none', 'None'), ('push_to_lock', 'Push-To-Lock'),
('auto_stop', 'Auto Stop'),
], string='Brakes')
walker_brake_type = fields.Selection([
('none', 'None'), ('bilateral', 'Bilateral'),
('one_hand', 'One Hand'),
], string='Brake Type')
walker_num_wheels = fields.Selection([
('two', 'Two'), ('three', 'Three'), ('four', 'Four'),
], string='Number of Wheels')
walker_wheel_size = fields.Selection([
('4_6', '4-6 inches'), ('6_8', '6-8 inches'), ('8_10', '8-10 inches'),
], string='Wheel Size')
walker_back_support = fields.Selection([
('yes', 'Yes'), ('no', 'No'),
], string='Back Support')
# Walker ADP options (checkboxes)
walker_adolescent_wheeled_walker = fields.Boolean(
string='Adolescent Size Paediatric Specific Wheeled Walker')
walker_adolescent_walking_frame = fields.Boolean(
string='Adolescent Size Paediatric Wheeled Walker Walking Frame')
walker_adolescent_standing_frame = fields.Boolean(
string='Adolescent Size Paediatric Standing Frame')
# =========================================================================
# POWER BASE / SCOOTER PRESCRIPTION (Section 2c)
# =========================================================================
# Note: power bases share seat_width, finished_back_height,
# finished_seat_to_floor_height, finished_leg_rest_length, seat_depth,
# and client_weight from the main measurement fields above.
# Scooters only require client_weight (field 6).
# Power ADP options (checkboxes from screenshot)
pw_adjustable_tension_back = fields.Boolean(
string='Adjustable Tension Back Upholstery')
pw_midline_control = fields.Boolean(string='Midline Control')
pw_manual_recline = fields.Boolean(string='Manual Recline Option')
pw_angle_adjustable_footplates = fields.Boolean(
string='Angle Adjustable Footplates (pair)')
pw_manual_elevating_legrests = fields.Boolean(
string='Manual Elevating Legrests (pair)')
pw_swingaway_bracket = fields.Boolean(string='Swingaway Mounting Bracket')
pw_front_riggings = fields.Boolean(string='One Piece 90/90 Front Riggings')
pw_seat_package_1 = fields.Boolean(
string='Seat Package 1 for Power Bases',
help='Includes frame, sling upholstery, armrests, footrests')
pw_seat_package_2 = fields.Boolean(
string='Seat Package 2 for Power Bases',
help='Includes deluxe seat and back, armrests, footrests')
pw_oxygen_tank = fields.Boolean(string='Oxygen Tank Holder')
pw_ventilator_tray = fields.Boolean(string='Ventilator Tray')
# Specialty Components (* require clinical rationale)
pw_specialty_1_joystick = fields.Boolean(
string='Specialty Controls 1 Non Standard Joystick*')
pw_specialty_2_chin = fields.Boolean(
string='Specialty Controls 2 Chin/Rim Control*')
pw_specialty_3_touch = fields.Boolean(
string='Specialty Controls 3 Simple Touch*')
pw_specialty_4_proximity = fields.Boolean(
string='Specialty Controls 4 Proximity Control*')
pw_specialty_5_breath = fields.Boolean(
string='Specialty Controls 5 Breath Control*')
pw_specialty_6_scanners = fields.Boolean(
string='Specialty Controls 6 Scanners*')
pw_auto_correction = fields.Boolean(string='Auto Correction System*')
pw_specialty_rationale = fields.Text(
string='Clinical Rationale for Specialty Components')
# Power Positioning Devices (require Justification for Funding Chart)
pw_power_tilt_only = fields.Boolean(string='Power Tilt Only')
pw_power_recline_only = fields.Boolean(string='Power Recline Only')
pw_power_tilt_recline = fields.Boolean(string='Power Tilt and Recline')
pw_power_elevating_footrests = fields.Boolean(
string='Power Elevating Footrests')
pw_multi_function_control = fields.Boolean(
string='Multi-Function Control Box')
pw_power_add_on = fields.Boolean(string='Power Add-On Device')
# Computed: convert to inches/lbs for rule evaluation
seat_width_inches = fields.Float(
compute='_compute_normalized_measurements', store=True)
seat_depth_inches = fields.Float(
compute='_compute_normalized_measurements', store=True)
back_height_inches = fields.Float(
compute='_compute_normalized_measurements', store=True)
client_weight_lbs = fields.Float(
compute='_compute_normalized_measurements', store=True)
# =========================================================================
# STEP 3: FRAME
# =========================================================================
frame_product_tmpl_id = fields.Many2one('product.template',
string='Frame Template')
frame_product_id = fields.Many2one('product.product',
string='Wheelchair Frame')
frame_notes = fields.Text(string='Frame Notes')
# =========================================================================
# STEP 4 & 5: SEATING, OPTIONS, ACCESSORIES (via lines)
# =========================================================================
line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
string='Selected Items')
upcharge_line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
domain=[('is_upcharge', '=', True)], string='Auto-Applied Upcharges')
# =========================================================================
# STEP 6: REVIEW & RESULT
# =========================================================================
sale_order_id = fields.Many2one('sale.order',
string='Generated Quotation', readonly=True, copy=False)
total_estimate = fields.Float(string='Total Estimate',
compute='_compute_totals', store=True)
total_adp_estimate = fields.Float(string='Estimated ADP Portion',
compute='_compute_totals', store=True)
total_client_estimate = fields.Float(string='Estimated Client Portion',
compute='_compute_totals', store=True)
notes = fields.Text(string='Additional Notes')
# Form state for save/resume
current_step = fields.Integer(string='Current Step', default=1)
form_data_json = fields.Text(string='Form State (JSON)',
help='Stores intermediate form state for save/resume')
# Generic equipment data (for non-wheelchair equipment types)
equipment_data_json = fields.Text(string='Equipment Data (JSON)',
help='JSON storage for equipment-specific field values. '
'Used by dynamic measurement/custom steps for non-wheelchair types.')
# =========================================================================
# SHARING & PUBLIC ACCESS
# =========================================================================
access_token = fields.Char(
string='Access Token', copy=False, index=True,
help='Token for public access without login')
portal_url = fields.Char(
string='Portal URL', compute='_compute_portal_url')
public_url = fields.Char(
string='Public URL', compute='_compute_public_url')
# =========================================================================
# COMPUTED FIELDS
# =========================================================================
@api.depends('client_first_name', 'client_last_name', 'partner_id')
def _compute_client_name(self):
for rec in self:
if rec.partner_id:
rec.client_name = rec.partner_id.name
elif rec.client_first_name or rec.client_last_name:
parts = [rec.client_first_name or '', rec.client_last_name or '']
rec.client_name = ' '.join(p for p in parts if p)
else:
rec.client_name = ''
@api.depends('reference', 'client_name')
def _compute_display_name(self):
for rec in self:
parts = [rec.reference or _('New')]
if rec.client_name:
parts.append(rec.client_name)
rec.display_name = ' - '.join(parts)
@api.depends(
'seat_width', 'seat_width_unit',
'seat_depth', 'seat_depth_unit',
'finished_back_height', 'back_height_unit',
'client_weight', 'client_weight_unit',
)
def _compute_normalized_measurements(self):
"""Convert all measurements to inches/lbs for consistent rule evaluation."""
for rec in self:
rec.seat_width_inches = (
rec.seat_width if rec.seat_width_unit == 'inches'
else rec.seat_width / 2.54
)
rec.seat_depth_inches = (
rec.seat_depth if rec.seat_depth_unit == 'inches'
else rec.seat_depth / 2.54
)
rec.back_height_inches = (
rec.finished_back_height if rec.back_height_unit == 'inches'
else rec.finished_back_height / 2.54
)
rec.client_weight_lbs = (
rec.client_weight if rec.client_weight_unit == 'lbs'
else rec.client_weight * 2.20462
)
@api.depends('line_ids.subtotal', 'line_ids.adp_portion', 'line_ids.client_portion')
def _compute_totals(self):
for rec in self:
rec.total_estimate = sum(rec.line_ids.mapped('subtotal'))
rec.total_adp_estimate = sum(rec.line_ids.mapped('adp_portion'))
rec.total_client_estimate = sum(rec.line_ids.mapped('client_portion'))
def _compute_portal_url(self):
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for rec in self:
rec.portal_url = f'{base}/my/quotation/builder/{rec.id}/edit' if rec.id else False
def _compute_public_url(self):
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
for rec in self:
if rec.access_token:
rec.public_url = f'{base}/quotation/form/{rec.access_token}'
else:
rec.public_url = False
# =========================================================================
# SHARING ACTIONS
# =========================================================================
def _generate_access_token(self):
"""Generate a unique access token for public sharing."""
for rec in self:
if not rec.access_token:
rec.access_token = uuid.uuid4().hex
def action_open_portal_form(self):
"""Open the portal assessment form in a new browser tab."""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': self.portal_url,
'target': 'new',
}
def action_generate_share_link(self):
"""Generate a public access token and show the shareable URL."""
self.ensure_one()
self._generate_access_token()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Share Link Generated'),
'message': _('Public URL copied to clipboard — '
'also visible in the Sharing section of this form.'),
'type': 'info',
'sticky': False,
},
}
# =========================================================================
# CRUD OVERRIDES
# =========================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('reference', _('New')) == _('New'):
vals['reference'] = self.env['ir.sequence'].next_by_code(
'fusion.wc.assessment') or _('New')
return super().create(vals_list)
# =========================================================================
# UPCHARGE RULE ENGINE
# =========================================================================
def _evaluate_upcharge_rules(self):
"""Evaluate all active upcharge rules against current assessment data.
Creates assessment lines for triggered rules.
Returns recordset of triggered rules.
"""
self.ensure_one()
# Remove previously auto-applied upcharges (allows re-evaluation)
self.line_ids.filtered(lambda l: l.is_upcharge).unlink()
rules = self.env['fusion.wc.upcharge.rule'].search([
('active', '=', True),
('equipment_type', 'in', [self.equipment_type, 'both']),
])
triggered = self.env['fusion.wc.upcharge.rule']
# Track exclusive groups — only the first matching rule per group applies
exclusive_triggered = {}
for rule in rules.sorted('sequence'):
# Check mutual exclusion
if rule.mutually_exclusive_group:
if rule.mutually_exclusive_group in exclusive_triggered:
continue
matched = False
if rule.trigger_type == 'measurement':
value = self._get_measurement_value(rule.measurement_field)
if value and self._compare_values(value, rule.comparison, rule.threshold_value):
matched = True
elif rule.trigger_type == 'weight':
weight = self.client_weight_lbs
if weight > rule.weight_min:
if not rule.weight_max or weight <= rule.weight_max:
matched = True
elif rule.trigger_type == 'dimension_mismatch':
val1 = self._get_measurement_value(rule.compare_field_1)
val2 = self._get_measurement_value(rule.compare_field_2)
if val1 and val2 and abs(val1 - val2) > 0.01:
matched = True
if matched:
self._create_upcharge_line(rule)
triggered |= rule
if rule.mutually_exclusive_group:
exclusive_triggered[rule.mutually_exclusive_group] = rule
return triggered
def _get_measurement_value(self, field_name):
"""Map measurement field selection to actual normalized (inches) value."""
field_map = {
'seat_width': self.seat_width_inches,
'seat_depth': self.seat_depth_inches,
'back_width': self.seat_width_inches, # backrest width defaults to seat width
'back_height': self.back_height_inches,
'seat_to_floor': (
self.finished_seat_to_floor_height
if self.seat_to_floor_unit == 'inches'
else self.finished_seat_to_floor_height / 2.54
),
'leg_rest_length': (
self.finished_leg_rest_length
if self.leg_rest_unit == 'inches'
else self.finished_leg_rest_length / 2.54
),
}
return field_map.get(field_name, 0.0)
@staticmethod
def _compare_values(value, comparison, threshold):
"""Evaluate a comparison between value and threshold."""
if comparison == 'gt':
return value > threshold
elif comparison == 'gte':
return value >= threshold
elif comparison == 'lt':
return value < threshold
elif comparison == 'eq':
return abs(value - threshold) < 0.01
elif comparison == 'neq':
return abs(value - threshold) >= 0.01
return False
def _create_upcharge_line(self, rule):
"""Create an assessment line from an upcharge rule."""
ADPDevice = self.env['fusion.adp.device.code'].sudo()
adp_device = ADPDevice.search(
[('device_code', '=', rule.adp_device_code), ('active', '=', True)],
limit=1
)
vals = {
'assessment_id': self.id,
'product_id': rule.product_id.id if rule.product_id else False,
'product_name': (
adp_device.device_description
if adp_device else rule.name
),
'quantity': 1,
'adp_device_code': rule.adp_device_code,
'adp_price': adp_device.adp_price if adp_device else 0,
'unit_price': adp_device.adp_price if adp_device else 0,
'is_upcharge': True,
'upcharge_rule_id': rule.id,
'upcharge_reason': rule.description or rule.name,
}
return self.env['fusion.wc.assessment.line'].create(vals)
# =========================================================================
# QUOTATION GENERATION
# =========================================================================
def action_generate_quotation(self):
"""Generate a draft sale.order from this assessment."""
self.ensure_one()
if self.sale_order_id:
raise UserError(_('A quotation has already been generated for this assessment.'))
# Run upcharge engine first
triggered_rules = self._evaluate_upcharge_rules()
# Resolve or create partner
partner = self._resolve_partner()
# Add frame as an assessment line if not already present
self._ensure_frame_line()
# Create sale order
so_vals = self._prepare_sale_order_vals(partner)
sale_order = self.env['sale.order'].sudo().create(so_vals)
# Create sale order lines from assessment lines
for line in self.line_ids:
sol_vals = self._prepare_sale_order_line_vals(sale_order, line)
self.env['sale.order.line'].sudo().create(sol_vals)
# Update assessment
self.write({
'sale_order_id': sale_order.id,
'state': 'quoted',
})
# Post chatter message
self._post_quotation_message(sale_order, triggered_rules)
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': sale_order.id,
'view_mode': 'form',
'target': 'current',
}
def _resolve_partner(self):
"""Get existing partner or create a new one."""
self.ensure_one()
if self.partner_id:
return self.partner_id
if not self.client_first_name and not self.client_last_name:
raise UserError(_('Please select an existing client or provide client name.'))
partner_vals = {
'name': self.client_name,
'phone': self.client_phone,
'email': self.client_email,
'street': self.client_street,
'street2': self.client_street2,
'city': self.client_city,
'state_id': self.client_state_id.id if self.client_state_id else False,
'zip': self.client_zip,
'country_id': self.client_country_id.id if self.client_country_id else False,
'customer_rank': 1,
}
partner = self.env['res.partner'].sudo().create(partner_vals)
self.partner_id = partner
return partner
def _ensure_frame_line(self):
"""Add the frame product as an assessment line if not already present."""
self.ensure_one()
# If we have a template but no resolved variant, resolve to first variant
if not self.frame_product_id and self.frame_product_tmpl_id:
variant = self.frame_product_tmpl_id.product_variant_ids[:1]
if variant:
self.frame_product_id = variant.id
if not self.frame_product_id:
return
# Pick the right frame section based on equipment type
frame_code_map = {
'manual_wheelchair': 'mw_frame',
'power_wheelchair': 'pw_frame',
'walker': 'walker_frame',
}
code = frame_code_map.get(self.equipment_type, 'mw_frame')
frame_section = self.env['fusion.wc.section'].search(
[('code', '=', code)], limit=1)
if not frame_section:
# Fall back to legacy code
frame_section = self.env['fusion.wc.section'].search(
[('code', '=', 'frame')], limit=1)
existing = self.line_ids.filtered(
lambda l: l.product_id == self.frame_product_id and not l.is_upcharge)
if existing:
return
# Build description from measurements
desc_parts = [self.frame_product_id.display_name]
if self.seat_width:
desc_parts.append(f"Seat Width: {self.seat_width}{self.seat_width_unit}")
if self.seat_depth:
desc_parts.append(f"Seat Depth: {self.seat_depth}{self.seat_depth_unit}")
if self.finished_seat_to_floor_height:
desc_parts.append(
f"Seat to Floor: {self.finished_seat_to_floor_height}"
f"{self.seat_to_floor_unit}")
if self.finished_leg_rest_length:
desc_parts.append(
f"Leg Rest: {self.finished_leg_rest_length}{self.leg_rest_unit}")
if self.back_cane_height:
desc_parts.append(
f"Cane Height: {self.back_cane_height}{self.cane_height_unit}")
product = self.frame_product_id
adp_code = product.product_tmpl_id.x_fc_adp_device_code or ''
adp_price = product.product_tmpl_id.x_fc_adp_price or product.lst_price
self.env['fusion.wc.assessment.line'].create({
'assessment_id': self.id,
'section_id': frame_section.id if frame_section else False,
'product_id': product.id,
'product_name': '\n'.join(desc_parts),
'quantity': 1,
'adp_device_code': adp_code,
'adp_price': adp_price,
'unit_price': product.lst_price,
})
def _prepare_sale_order_vals(self, partner):
"""Prepare values for sale.order creation."""
self.ensure_one()
# Map client_type to fusion_claims x_fc_client_type
client_type_map = {
'reg': 'REG',
'ods': 'ODS',
'acs': 'ACS',
'owp': 'OWP',
'ltc': 'LTC',
'sen': 'SEN',
'cca': 'CCA',
}
vals = {
'partner_id': partner.id,
'user_id': self.sales_rep_id.id,
'state': 'draft',
'origin': f'WC Assessment: {self.reference}',
'wc_assessment_id': self.id,
}
# Set fusion_claims fields if available
if hasattr(self.env['sale.order'], 'x_fc_sale_type'):
vals['x_fc_sale_type'] = 'adp'
if hasattr(self.env['sale.order'], 'x_fc_client_type'):
vals['x_fc_client_type'] = client_type_map.get(self.client_type, 'REG')
if hasattr(self.env['sale.order'], 'x_fc_adp_application_status'):
vals['x_fc_adp_application_status'] = 'quotation'
if self.authorizer_id and hasattr(self.env['sale.order'], 'x_fc_authorizer_id'):
vals['x_fc_authorizer_id'] = self.authorizer_id.id
return vals
def _prepare_sale_order_line_vals(self, sale_order, line):
"""Prepare values for sale.order.line creation from assessment line."""
vals = {
'order_id': sale_order.id,
'product_id': line.product_id.id if line.product_id else False,
'name': line.product_name or (
line.product_id.display_name if line.product_id else line.adp_device_code
),
'product_uom_qty': line.quantity,
'price_unit': line.unit_price or (
line.product_id.lst_price if line.product_id else 0
),
}
return vals
def _post_quotation_message(self, sale_order, triggered_rules):
"""Post a chatter message summarizing the assessment."""
self.ensure_one()
lines_html = ''
for line in self.line_ids:
icon = '&#x26A1;' if line.is_upcharge else '&#x2705;'
lines_html += (
f'<li>{icon} {line.product_name or line.adp_device_code} '
f'x{line.quantity} — ${line.unit_price:.2f}</li>'
)
measurements_html = ''
if self.seat_width:
measurements_html += (
f'<li>Seat Width: {self.seat_width} {self.seat_width_unit}</li>')
if self.seat_depth:
measurements_html += (
f'<li>Seat Depth: {self.seat_depth} {self.seat_depth_unit}</li>')
if self.finished_seat_to_floor_height:
measurements_html += (
f'<li>Seat to Floor: {self.finished_seat_to_floor_height} '
f'{self.seat_to_floor_unit}</li>')
if self.back_cane_height:
measurements_html += (
f'<li>Back Cane Height: {self.back_cane_height} '
f'{self.cane_height_unit}</li>')
if self.finished_back_height:
measurements_html += (
f'<li>Finished Back Height: {self.finished_back_height} '
f'{self.back_height_unit}</li>')
if self.finished_leg_rest_length:
measurements_html += (
f'<li>Leg Rest Length: {self.finished_leg_rest_length} '
f'{self.leg_rest_unit}</li>')
if self.client_weight:
measurements_html += (
f'<li>Client Weight: {self.client_weight} {self.client_weight_unit}</li>')
upcharges_html = ''
if triggered_rules:
upcharges_html = '<h4>Auto-Applied Upcharges</h4><ul>'
for rule in triggered_rules:
upcharges_html += (
f'<li>&#x26A1; {rule.name} ({rule.adp_device_code})</li>')
upcharges_html += '</ul>'
body = Markup(
f'<h3>Wheelchair Assessment Completed</h3>'
f'<p>Assessment: {self.reference}<br/>'
f'Equipment: {dict(self._fields["equipment_type"].selection).get(self.equipment_type, "")}<br/>'
f'Build Type: {dict(self._fields["build_type"].selection).get(self.build_type, "")}</p>'
f'<h4>Measurements</h4><ul>{measurements_html}</ul>'
f'<h4>Selected Items</h4><ul>{lines_html}</ul>'
f'{upcharges_html}'
)
sale_order.message_post(body=body, message_type='comment',
subtype_xmlid='mail.mt_note')
# =========================================================================
# ACTIONS
# =========================================================================
def action_mark_review(self):
"""Mark assessment as ready for review."""
for rec in self:
rec.state = 'review'
def action_cancel(self):
"""Cancel the assessment."""
for rec in self:
rec.state = 'cancelled'
def action_reset_draft(self):
"""Reset to draft."""
for rec in self:
if rec.state == 'cancelled':
rec.state = 'draft'
def action_view_quotation(self):
"""Open the generated quotation."""
self.ensure_one()
if not self.sale_order_id:
raise UserError(_('No quotation has been generated yet.'))
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_wc_section_user,fusion.wc.section.user,model_fusion_wc_section,base.group_user,1,0,0,0
access_wc_section_manager,fusion.wc.section.manager,model_fusion_wc_section,sales_team.group_sale_manager,1,1,1,1
access_wc_section_portal,fusion.wc.section.portal,model_fusion_wc_section,base.group_portal,1,0,0,0
access_wc_section_option_user,fusion.wc.section.option.user,model_fusion_wc_section_option,base.group_user,1,0,0,0
access_wc_section_option_manager,fusion.wc.section.option.manager,model_fusion_wc_section_option,sales_team.group_sale_manager,1,1,1,1
access_wc_section_option_portal,fusion.wc.section.option.portal,model_fusion_wc_section_option,base.group_portal,1,0,0,0
access_wc_upcharge_rule_user,fusion.wc.upcharge.rule.user,model_fusion_wc_upcharge_rule,base.group_user,1,0,0,0
access_wc_upcharge_rule_manager,fusion.wc.upcharge.rule.manager,model_fusion_wc_upcharge_rule,sales_team.group_sale_manager,1,1,1,1
access_wc_upcharge_rule_portal,fusion.wc.upcharge.rule.portal,model_fusion_wc_upcharge_rule,base.group_portal,1,0,0,0
access_wc_assessment_salesman,fusion.wc.assessment.salesman,model_fusion_wc_assessment,sales_team.group_sale_salesman,1,1,1,0
access_wc_assessment_manager,fusion.wc.assessment.manager,model_fusion_wc_assessment,sales_team.group_sale_manager,1,1,1,1
access_wc_assessment_portal,fusion.wc.assessment.portal,model_fusion_wc_assessment,base.group_portal,1,1,1,0
access_wc_assessment_line_salesman,fusion.wc.assessment.line.salesman,model_fusion_wc_assessment_line,sales_team.group_sale_salesman,1,1,1,1
access_wc_assessment_line_manager,fusion.wc.assessment.line.manager,model_fusion_wc_assessment_line,sales_team.group_sale_manager,1,1,1,1
access_wc_assessment_line_portal,fusion.wc.assessment.line.portal,model_fusion_wc_assessment_line,base.group_portal,1,1,1,0
access_wc_config_flow_user,fusion.wc.config.flow.user,model_fusion_wc_config_flow,base.group_user,1,0,0,0
access_wc_config_flow_manager,fusion.wc.config.flow.manager,model_fusion_wc_config_flow,sales_team.group_sale_manager,1,1,1,1
access_wc_config_flow_node_user,fusion.wc.config.flow.node.user,model_fusion_wc_config_flow_node,base.group_user,1,0,0,0
access_wc_config_flow_node_manager,fusion.wc.config.flow.node.manager,model_fusion_wc_config_flow_node,sales_team.group_sale_manager,1,1,1,1
access_wc_config_flow_connection_user,fusion.wc.config.flow.connection.user,model_fusion_wc_config_flow_connection,base.group_user,1,0,0,0
access_wc_config_flow_connection_manager,fusion.wc.config.flow.connection.manager,model_fusion_wc_config_flow_connection,sales_team.group_sale_manager,1,1,1,1
access_wc_config_flow_node_option_user,fusion.wc.config.flow.node.option.user,model_fusion_wc_config_flow_node_option,base.group_user,1,0,0,0
access_wc_config_flow_node_option_manager,fusion.wc.config.flow.node.option.manager,model_fusion_wc_config_flow_node_option,sales_team.group_sale_manager,1,1,1,1
access_equipment_type_user,fusion.equipment.type.user,model_fusion_equipment_type,base.group_user,1,0,0,0
access_equipment_type_manager,fusion.equipment.type.manager,model_fusion_equipment_type,sales_team.group_sale_manager,1,1,1,1
access_equipment_type_portal,fusion.equipment.type.portal,model_fusion_equipment_type,base.group_portal,1,0,0,0
access_wc_config_flow_step_user,fusion.wc.config.flow.step.user,model_fusion_wc_config_flow_step,base.group_user,1,0,0,0
access_wc_config_flow_step_manager,fusion.wc.config.flow.step.manager,model_fusion_wc_config_flow_step,sales_team.group_sale_manager,1,1,1,1
access_wc_config_flow_step_portal,fusion.wc.config.flow.step.portal,model_fusion_wc_config_flow_step,base.group_portal,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_wc_section_user fusion.wc.section.user model_fusion_wc_section base.group_user 1 0 0 0
3 access_wc_section_manager fusion.wc.section.manager model_fusion_wc_section sales_team.group_sale_manager 1 1 1 1
4 access_wc_section_portal fusion.wc.section.portal model_fusion_wc_section base.group_portal 1 0 0 0
5 access_wc_section_option_user fusion.wc.section.option.user model_fusion_wc_section_option base.group_user 1 0 0 0
6 access_wc_section_option_manager fusion.wc.section.option.manager model_fusion_wc_section_option sales_team.group_sale_manager 1 1 1 1
7 access_wc_section_option_portal fusion.wc.section.option.portal model_fusion_wc_section_option base.group_portal 1 0 0 0
8 access_wc_upcharge_rule_user fusion.wc.upcharge.rule.user model_fusion_wc_upcharge_rule base.group_user 1 0 0 0
9 access_wc_upcharge_rule_manager fusion.wc.upcharge.rule.manager model_fusion_wc_upcharge_rule sales_team.group_sale_manager 1 1 1 1
10 access_wc_upcharge_rule_portal fusion.wc.upcharge.rule.portal model_fusion_wc_upcharge_rule base.group_portal 1 0 0 0
11 access_wc_assessment_salesman fusion.wc.assessment.salesman model_fusion_wc_assessment sales_team.group_sale_salesman 1 1 1 0
12 access_wc_assessment_manager fusion.wc.assessment.manager model_fusion_wc_assessment sales_team.group_sale_manager 1 1 1 1
13 access_wc_assessment_portal fusion.wc.assessment.portal model_fusion_wc_assessment base.group_portal 1 1 1 0
14 access_wc_assessment_line_salesman fusion.wc.assessment.line.salesman model_fusion_wc_assessment_line sales_team.group_sale_salesman 1 1 1 1
15 access_wc_assessment_line_manager fusion.wc.assessment.line.manager model_fusion_wc_assessment_line sales_team.group_sale_manager 1 1 1 1
16 access_wc_assessment_line_portal fusion.wc.assessment.line.portal model_fusion_wc_assessment_line base.group_portal 1 1 1 0
17 access_wc_config_flow_user fusion.wc.config.flow.user model_fusion_wc_config_flow base.group_user 1 0 0 0
18 access_wc_config_flow_manager fusion.wc.config.flow.manager model_fusion_wc_config_flow sales_team.group_sale_manager 1 1 1 1
19 access_wc_config_flow_node_user fusion.wc.config.flow.node.user model_fusion_wc_config_flow_node base.group_user 1 0 0 0
20 access_wc_config_flow_node_manager fusion.wc.config.flow.node.manager model_fusion_wc_config_flow_node sales_team.group_sale_manager 1 1 1 1
21 access_wc_config_flow_connection_user fusion.wc.config.flow.connection.user model_fusion_wc_config_flow_connection base.group_user 1 0 0 0
22 access_wc_config_flow_connection_manager fusion.wc.config.flow.connection.manager model_fusion_wc_config_flow_connection sales_team.group_sale_manager 1 1 1 1
23 access_wc_config_flow_node_option_user fusion.wc.config.flow.node.option.user model_fusion_wc_config_flow_node_option base.group_user 1 0 0 0
24 access_wc_config_flow_node_option_manager fusion.wc.config.flow.node.option.manager model_fusion_wc_config_flow_node_option sales_team.group_sale_manager 1 1 1 1
25 access_equipment_type_user fusion.equipment.type.user model_fusion_equipment_type base.group_user 1 0 0 0
26 access_equipment_type_manager fusion.equipment.type.manager model_fusion_equipment_type sales_team.group_sale_manager 1 1 1 1
27 access_equipment_type_portal fusion.equipment.type.portal model_fusion_equipment_type base.group_portal 1 0 0 0
28 access_wc_config_flow_step_user fusion.wc.config.flow.step.user model_fusion_wc_config_flow_step base.group_user 1 0 0 0
29 access_wc_config_flow_step_manager fusion.wc.config.flow.step.manager model_fusion_wc_config_flow_step sales_team.group_sale_manager 1 1 1 1
30 access_wc_config_flow_step_portal fusion.wc.config.flow.step.portal model_fusion_wc_config_flow_step base.group_portal 1 0 0 0

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Portal sales rep: own assessments only -->
<record id="rule_wc_assessment_portal" model="ir.rule">
<field name="name">Portal: Own WC Assessments</field>
<field name="model_id" ref="model_fusion_wc_assessment"/>
<field name="domain_force">[('sales_rep_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal: assessment lines follow assessment access -->
<record id="rule_wc_assessment_line_portal" model="ir.rule">
<field name="name">Portal: Own WC Assessment Lines</field>
<field name="model_id" ref="model_fusion_wc_assessment_line"/>
<field name="domain_force">[('assessment_id.sales_rep_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- IR Sequence for assessment references -->
<record id="seq_wc_assessment" model="ir.sequence">
<field name="name">Wheelchair Assessment</field>
<field name="code">fusion.wc.assessment</field>
<field name="prefix">WCA-</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,410 @@
/* =================================================================
Wheelchair Assessment Form — Dark / Light Compatible
Uses Bootstrap 5.3 CSS custom properties for full theme support.
No color-mix(), no absolute connectors, no transform overlap.
================================================================= */
.wc-assessment-form {
max-width: 1000px;
margin: 0 auto;
padding: 0 15px;
}
/* -----------------------------------------------------------------
Step Indicators — always visible, solid colours, no opacity tricks
----------------------------------------------------------------- */
.wc-steps {
border-bottom: 2px solid var(--bs-border-color, #dee2e6);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.wc-step-indicator {
cursor: pointer;
transition: none;
}
.wc-step-number {
width: 36px;
height: 36px;
background: #e9ecef;
color: #6c757d;
font-weight: 700;
font-size: 14px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.wc-step-label {
font-size: 0.72rem;
color: #6c757d;
letter-spacing: 0.02em;
}
/* Active step — blue circle */
.wc-step-indicator.active .wc-step-number {
background: #0d6efd;
color: #fff;
box-shadow: 0 0 0 4px rgba(13, 110, 253, 0.2);
}
.wc-step-indicator.active .wc-step-label {
color: #0d6efd;
font-weight: 600;
}
/* Completed step — green circle with checkmark */
.wc-step-indicator.completed .wc-step-number {
background: #198754;
color: #fff;
}
.wc-step-indicator.completed .wc-step-label {
color: #198754;
font-weight: 500;
}
/* -----------------------------------------------------------------
Step panels — NO animation (animation creates stacking context
that traps z-index, breaking search dropdowns)
----------------------------------------------------------------- */
.wc-step {
position: relative;
}
/* -----------------------------------------------------------------
Step 1 Section Cards (Client, Equipment)
----------------------------------------------------------------- */
.wc-section-card {
border: 1px solid var(--bs-border-color);
border-radius: 0.75rem;
}
.wc-section-card > .card-header {
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
padding: 0.65rem 1rem;
border-radius: 0.75rem 0.75rem 0 0;
}
.wc-section-card > .card-header h5 {
font-size: 0.9rem;
font-weight: 600;
color: var(--bs-body-color);
}
.wc-section-card > .card-body {
padding: 1rem;
}
/* -----------------------------------------------------------------
Radio Buttons as Toggles — hardcoded colours, no CSS variables.
JS sets inline styles as belt-and-suspenders.
----------------------------------------------------------------- */
.wc-radio-btn {
position: relative;
padding: 0.5rem 1rem;
border: 2px solid #0d6efd;
color: #0d6efd;
background: transparent;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 500;
transition: background-color 0.15s ease, color 0.15s ease;
}
.wc-radio-btn input[type="radio"] {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
margin: 0;
padding: 0;
}
/* Primary variant — selected */
.wc-radio-btn.active {
background-color: #0d6efd !important;
color: #fff !important;
border-color: #0d6efd !important;
}
/* Secondary variant (.btn-outline-secondary) — unselected */
.wc-radio-btn.btn-outline-secondary {
border-color: #6c757d;
color: #6c757d;
}
/* Secondary variant — selected */
.wc-radio-btn.btn-outline-secondary.active {
background-color: #6c757d !important;
color: #fff !important;
border-color: #6c757d !important;
}
/* -----------------------------------------------------------------
Measurement Fields
----------------------------------------------------------------- */
.wc-measurement-field {
background: var(--bs-tertiary-bg);
transition: border-color 0.2s, box-shadow 0.2s;
border-radius: 0.5rem !important;
}
.wc-measurement-field:focus-within {
border-color: var(--bs-primary) !important;
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.12);
}
.wc-upcharge-badge {
white-space: nowrap;
font-size: 0.75rem;
}
/* -----------------------------------------------------------------
Option Cards (seating sections, ADP options, accessories)
----------------------------------------------------------------- */
.wc-option-card {
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem !important;
background: var(--bs-body-bg);
}
.wc-option-card:hover {
border-color: rgba(var(--bs-primary-rgb), 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.wc-option-card.border-primary {
border-width: 2px !important;
border-color: var(--bs-primary) !important;
background-color: var(--bs-primary-bg-subtle) !important;
}
.wc-option-card .card-body {
padding: 0.625rem 0.75rem !important;
}
/* Option card label */
.wc-option-label {
font-size: 0.82rem;
line-height: 1.4;
flex: 1;
min-width: 0;
word-wrap: break-word;
}
/* -----------------------------------------------------------------
Search Containers — z-index must beat ALL sibling content below.
position:relative creates a stacking context so the absolute
dropdown inside floats above everything that follows.
----------------------------------------------------------------- */
.wc-search-container {
position: relative;
z-index: 100;
}
/* -----------------------------------------------------------------
Search Dropdowns (client, frame, section product search)
position:absolute + high z-index within the search container
----------------------------------------------------------------- */
.wc-search-results,
#clientSearchResults,
#frameSearchResults {
position: absolute;
z-index: 1060;
width: 100%;
background: var(--bs-body-bg);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
border-radius: 0 0 0.5rem 0.5rem;
border: 1px solid var(--bs-border-color);
border-top: none;
max-height: 300px;
overflow-y: auto;
}
.wc-search-results .list-group-item,
#clientSearchResults .list-group-item,
#frameSearchResults .list-group-item {
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
background: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
transition: background 0.1s ease;
cursor: pointer;
}
.wc-search-results .list-group-item:hover,
#clientSearchResults .list-group-item:hover,
#frameSearchResults .list-group-item:hover {
background: var(--bs-tertiary-bg);
}
/* -----------------------------------------------------------------
Selected Frame Card
----------------------------------------------------------------- */
#selectedFrame > .card {
border-left: 4px solid var(--bs-primary);
background: var(--bs-primary-bg-subtle);
border-radius: 0.5rem;
}
/* -----------------------------------------------------------------
Frame Configurator Panel
----------------------------------------------------------------- */
.wc-configurator-panel {
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 1rem;
margin-top: 0.75rem;
}
.wc-configurator-panel .wc-config-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--bs-border-color);
}
.wc-configurator-panel .wc-config-title i {
color: var(--bs-primary);
}
.wc-config-attr-group {
margin-bottom: 0.625rem;
}
.wc-config-attr-group .wc-config-attr-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
margin-bottom: 0.25rem;
}
.wc-config-attr-group .form-select {
border-color: var(--bs-border-color);
background-color: var(--bs-body-bg);
font-size: 0.875rem;
border-radius: 0.375rem;
}
.wc-config-attr-group .form-select:focus {
border-color: var(--bs-primary);
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.12);
}
.wc-variant-resolved {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.35em 0.65em;
font-size: 0.8rem;
font-weight: 500;
background: rgba(var(--bs-success-rgb), 0.1);
color: var(--bs-success);
border: 1px solid rgba(var(--bs-success-rgb), 0.25);
border-radius: 2rem;
margin-top: 0.5rem;
}
/* -----------------------------------------------------------------
Accordion Sections (Step 4 seating)
----------------------------------------------------------------- */
.wc-assessment-form .accordion-button:not(.collapsed) {
background-color: #e8f0fe;
color: #0d6efd;
}
.wc-assessment-form .accordion-item {
border-radius: 0.5rem !important;
margin-bottom: 0.75rem;
border: 1px solid #dee2e6 !important;
overflow: hidden;
}
.wc-assessment-form .accordion-item:last-child {
margin-bottom: 0;
}
.wc-assessment-form .accordion-button {
border-radius: 0.5rem !important;
font-weight: 600;
}
.wc-assessment-form .accordion-button:not(.collapsed) {
border-radius: 0.5rem 0.5rem 0 0 !important;
}
.wc-assessment-form .accordion-body {
position: relative;
}
/* -----------------------------------------------------------------
Review Table (Step 6)
----------------------------------------------------------------- */
#reviewSummary .table th {
background: var(--bs-tertiary-bg);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--bs-secondary-color);
}
/* Review measurements card */
.wc-step[data-step="6"] .card {
border-color: var(--bs-border-color);
border-radius: 0.5rem;
}
.wc-step[data-step="6"] .card-header {
background: var(--bs-tertiary-bg);
border-bottom-color: var(--bs-border-color);
}
/* -----------------------------------------------------------------
Navigation Buttons
----------------------------------------------------------------- */
.wc-assessment-form .border-top {
border-color: var(--bs-border-color) !important;
}
/* -----------------------------------------------------------------
Responsive
----------------------------------------------------------------- */
@media (max-width: 768px) {
.wc-step-label {
display: none;
}
.wc-step-number {
width: 28px;
height: 28px;
font-size: 12px;
}
.wc-radio-btn {
padding: 0.375rem 0.75rem;
font-size: 0.85rem;
}
.wc-option-label {
font-size: 0.8rem;
}
.wc-configurator-panel {
padding: 0.75rem;
}
}

View File

@@ -0,0 +1,705 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component, useState, onWillStart, onMounted, onWillUnmount, useRef } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
/* ──────────────────────────────────────────────────────────────────
Constants
────────────────────────────────────────────────────────────────── */
const NODE_TYPES = {
start: { label: 'Start', icon: 'fa-play', color: '#10b981', width: 140, height: 50 },
end: { label: 'End', icon: 'fa-stop', color: '#ef4444', width: 140, height: 50 },
decision: { label: 'Decision', icon: 'fa-code-fork', color: '#f59e0b', width: 180, height: 80 },
option_group: { label: 'Option Group', icon: 'fa-list-ul', color: '#3b82f6', width: 200, height: 80 },
product_select: { label: 'Product Selection', icon: 'fa-shopping-cart', color: '#8b5cf6', width: 200, height: 80 },
measurement_check: { label: 'Measurement Check', icon: 'fa-ruler', color: '#f97316', width: 200, height: 80 },
action: { label: 'Action', icon: 'fa-bolt', color: '#14b8a6', width: 180, height: 70 },
};
const GRID_SIZE = 20;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 3;
const PORT_RADIUS = 7;
/* ──────────────────────────────────────────────────────────────────
Utility helpers
────────────────────────────────────────────────────────────────── */
function bezierPath(x1, y1, x2, y2) {
const dx = Math.abs(x2 - x1) * 0.5;
return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
}
function getPortsForNode(node) {
const meta = NODE_TYPES[node.node_type] || NODE_TYPES.action;
const w = meta.width;
const h = meta.height;
const ports = [];
// Input port (all except start)
if (node.node_type !== 'start') {
ports.push({ key: 'in', type: 'input', x: 0, y: h / 2 });
}
// Output ports
if (node.node_type === 'decision') {
ports.push({ key: 'true', type: 'output', x: w, y: h * 0.33, label: 'Yes' });
ports.push({ key: 'false', type: 'output', x: w, y: h * 0.67, label: 'No' });
} else if (node.node_type === 'measurement_check') {
ports.push({ key: 'pass', type: 'output', x: w, y: h * 0.33, label: 'Pass' });
ports.push({ key: 'fail', type: 'output', x: w, y: h * 0.67, label: 'Fail' });
} else if (node.node_type === 'option_group' && node.node_options && node.node_options.length) {
const count = node.node_options.length;
node.node_options.forEach((opt, i) => {
ports.push({
key: opt.port_key || `opt_${opt.sequence || (i * 10 + 10)}`,
type: 'output',
x: w,
y: h * ((i + 1) / (count + 1)),
label: opt.name,
});
});
} else if (node.node_type !== 'end') {
ports.push({ key: 'out', type: 'output', x: w, y: h / 2 });
}
return ports;
}
function snapToGrid(val) {
return Math.round(val / GRID_SIZE) * GRID_SIZE;
}
/* ══════════════════════════════════════════════════════════════════
FlowDesignerAction — main OWL client action
══════════════════════════════════════════════════════════════════ */
export class FlowDesignerAction extends Component {
static template = "fusion_quotations.FlowDesignerAction";
static props = { "*": true };
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.notification = useService("notification");
this.svgRef = useRef("svgCanvas");
this.canvasGroupRef = useRef("canvasGroup");
this.flowId = this.props.action?.context?.active_id || false;
this.state = useState({
flowName: '',
equipmentType: '',
nodes: [],
connections: [],
selectedNodeId: null,
selectedConnectionId: null,
dirty: false,
saving: false,
loading: true,
panelOpen: false,
// Viewport
viewX: 0,
viewY: 0,
zoom: 1,
});
// Interaction state (non-reactive, doesn't need re-render)
this._dragging = null; // { nodeId, startX, startY, origX, origY }
this._panning = null; // { startX, startY, origVX, origVY }
this._connecting = null; // { sourceNodeId, sourcePort, tempX, tempY }
this._tempLine = null; // SVG path element for connection preview
onWillStart(async () => {
if (this.flowId) {
await this._loadGraph();
}
});
onMounted(() => {
this._bindCanvasEvents();
});
onWillUnmount(() => {
this._unbindCanvasEvents();
});
}
/* ──────────────────────────────────────────────────────────────
Data loading / saving
────────────────────────────────────────────────────────────── */
async _loadGraph() {
this.state.loading = true;
try {
const graph = await this.orm.call(
'fusion.wc.config.flow',
'load_flow_graph',
[this.flowId]
);
this.state.flowName = graph.name || '';
this.state.equipmentType = graph.equipment_type || '';
this.state.nodes = graph.nodes || [];
this.state.connections = graph.connections || [];
if (graph.canvas) {
this.state.viewX = graph.canvas.x || 0;
this.state.viewY = graph.canvas.y || 0;
this.state.zoom = graph.canvas.zoom || 1;
}
} catch (e) {
this.notification.add(_t("Error loading flow: ") + e.message, { type: "danger" });
console.error("Load flow error:", e);
}
this.state.loading = false;
}
async _saveGraph() {
if (!this.flowId) return;
this.state.saving = true;
try {
await this.orm.call(
'fusion.wc.config.flow',
'save_flow_graph',
[this.flowId, {
canvas: { x: this.state.viewX, y: this.state.viewY, zoom: this.state.zoom },
nodes: this.state.nodes,
connections: this.state.connections,
}]
);
this.state.dirty = false;
this.notification.add(_t("Flow saved successfully"), { type: "success" });
// Reload to get real IDs from server
await this._loadGraph();
} catch (e) {
this.notification.add(_t("Error saving flow: ") + e.message, { type: "danger" });
console.error("Save flow error:", e);
}
this.state.saving = false;
}
/* ──────────────────────────────────────────────────────────────
Coordinate transforms
────────────────────────────────────────────────────────────── */
screenToCanvas(sx, sy) {
const svg = this.svgRef.el;
if (!svg) return { x: sx, y: sy };
const rect = svg.getBoundingClientRect();
return {
x: (sx - rect.left - this.state.viewX) / this.state.zoom,
y: (sy - rect.top - this.state.viewY) / this.state.zoom,
};
}
/* ──────────────────────────────────────────────────────────────
Computed data for template
────────────────────────────────────────────────────────────── */
get transformStr() {
return `translate(${this.state.viewX}, ${this.state.viewY}) scale(${this.state.zoom})`;
}
get nodesWithPorts() {
return this.state.nodes.map(n => {
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
return {
...n,
width: meta.width,
height: meta.height,
meta,
ports: getPortsForNode(n),
isSelected: n.id === this.state.selectedNodeId,
};
});
}
get connectionPaths() {
const nodeMap = {};
for (const n of this.state.nodes) {
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
nodeMap[n.id] = { ...n, width: meta.width, height: meta.height, ports: getPortsForNode(n) };
}
return this.state.connections.map(c => {
const src = nodeMap[c.source_node_id];
const tgt = nodeMap[c.target_node_id];
if (!src || !tgt) return null;
const srcPort = src.ports.find(p => p.key === c.source_port) || src.ports.find(p => p.type === 'output');
const tgtPort = tgt.ports.find(p => p.type === 'input') || { x: 0, y: tgt.height / 2 };
if (!srcPort) return null;
const x1 = src.pos_x + srcPort.x;
const y1 = src.pos_y + srcPort.y;
const x2 = tgt.pos_x + tgtPort.x;
const y2 = tgt.pos_y + tgtPort.y;
const label = c.label || '';
const labelW = Math.max(label.length * 7 + 16, 32); // approx width from char count
return {
...c,
path: bezierPath(x1, y1, x2, y2),
isSelected: c.id === this.state.selectedConnectionId,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2,
labelW,
};
}).filter(Boolean);
}
getNodeTypeLabel(nodeType) {
return (NODE_TYPES[nodeType] || {}).label || nodeType;
}
/* ──────────────────────────────────────────────────────────────
Toolbar / Palette actions
────────────────────────────────────────────────────────────── */
onAddNode(ev) {
const nodeType = ev.currentTarget.dataset.nodeType;
if (!nodeType) return;
const meta = NODE_TYPES[nodeType];
if (!meta) return;
// Place in center of current viewport
const svg = this.svgRef.el;
const rect = svg ? svg.getBoundingClientRect() : { width: 800, height: 600 };
const pos = this.screenToCanvas(rect.width / 2, rect.height / 2);
const tempId = 'new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
const newNode = {
id: tempId,
name: meta.label,
node_type: nodeType,
pos_x: snapToGrid(pos.x - meta.width / 2),
pos_y: snapToGrid(pos.y - meta.height / 2),
color: meta.color,
icon: meta.icon,
section_id: false,
section_name: '',
decision_field: '',
decision_operator: '',
decision_value: '',
measurement_field: '',
comparison: '',
threshold_value: 0,
action_type: '',
target_option_ids: [],
target_step: 0,
config_json: '{}',
node_options: [],
};
this.state.nodes.push(newNode);
this.state.selectedNodeId = tempId;
this.state.selectedConnectionId = null;
this.state.panelOpen = true;
this.state.dirty = true;
}
onDeleteSelected() {
if (this.state.selectedConnectionId) {
this.state.connections = this.state.connections.filter(
c => c.id !== this.state.selectedConnectionId
);
this.state.selectedConnectionId = null;
this.state.dirty = true;
}
if (this.state.selectedNodeId) {
const nodeId = this.state.selectedNodeId;
this.state.nodes = this.state.nodes.filter(n => n.id !== nodeId);
this.state.connections = this.state.connections.filter(
c => c.source_node_id !== nodeId && c.target_node_id !== nodeId
);
this.state.selectedNodeId = null;
this.state.panelOpen = false;
this.state.dirty = true;
}
}
onSave() {
this._saveGraph();
}
onZoomIn() {
this.state.zoom = Math.min(MAX_ZOOM, this.state.zoom * 1.2);
}
onZoomOut() {
this.state.zoom = Math.max(MIN_ZOOM, this.state.zoom / 1.2);
}
onZoomReset() {
this.state.zoom = 1;
this.state.viewX = 0;
this.state.viewY = 0;
}
onBack() {
// Navigate back to the flow form / list.
// history.back() is the most reliable way to return to the
// previous Odoo view that opened this client action.
window.history.back();
}
/* ──────────────────────────────────────────────────────────────
Canvas events
────────────────────────────────────────────────────────────── */
_bindCanvasEvents() {
const svg = this.svgRef.el;
if (!svg) return;
this._onMouseDown = this._handleMouseDown.bind(this);
this._onMouseMove = this._handleMouseMove.bind(this);
this._onMouseUp = this._handleMouseUp.bind(this);
this._onWheel = this._handleWheel.bind(this);
this._onKeyDown = this._handleKeyDown.bind(this);
svg.addEventListener('mousedown', this._onMouseDown);
window.addEventListener('mousemove', this._onMouseMove);
window.addEventListener('mouseup', this._onMouseUp);
svg.addEventListener('wheel', this._onWheel, { passive: false });
window.addEventListener('keydown', this._onKeyDown);
}
_unbindCanvasEvents() {
const svg = this.svgRef.el;
if (svg) {
svg.removeEventListener('mousedown', this._onMouseDown);
svg.removeEventListener('wheel', this._onWheel);
}
window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('mouseup', this._onMouseUp);
window.removeEventListener('keydown', this._onKeyDown);
}
/* ──────────────────────────────────────────────────────────────
Port interaction — pure coordinate math.
No DOM queries (elementsFromPoint / ev.target) — works
entirely from component state + getPortsForNode().
────────────────────────────────────────────────────────────── */
/**
* Find the nearest port within hit radius using coordinate math.
* @param {number} cx — x in canvas coordinate space
* @param {number} cy — y in canvas coordinate space
* @param {'input'|'output'} portType
* @returns {{ node: Object, port: Object, x: number, y: number }|null}
*/
_findPortNear(cx, cy, portType) {
const HIT_R = 20; // canvas-unit hit radius (~20px at zoom 1)
const rSq = HIT_R * HIT_R;
let best = null;
let bestDist = Infinity;
for (const node of this.state.nodes) {
const ports = getPortsForNode(node);
for (const port of ports) {
if (port.type !== portType) continue;
const px = node.pos_x + port.x;
const py = node.pos_y + port.y;
const dx = cx - px;
const dy = cy - py;
const distSq = dx * dx + dy * dy;
if (distSq <= rSq && distSq < bestDist) {
bestDist = distSq;
best = { node, port, x: px, y: py };
}
}
}
return best;
}
_handleMouseDown(ev) {
if (ev.button !== 0 && ev.button !== 1) return;
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
// ── 1. Port detection via coordinate math ──
// No DOM queries — purely checks distance to port centres.
if (ev.button === 0) {
const outHit = this._findPortNear(canvasPos.x, canvasPos.y, 'output');
if (outHit) {
this._connecting = {
sourceNodeId: outHit.node.id,
sourcePort: outHit.port.key,
startX: outHit.x,
startY: outHit.y,
};
// Create temp bezier for visual feedback
const svgNs = 'http://www.w3.org/2000/svg';
this._tempLine = document.createElementNS(svgNs, 'path');
this._tempLine.classList.add('fd-connection-temp');
this._tempLine.setAttribute('stroke-width', '2');
this._tempLine.setAttribute('pointer-events', 'none');
const group = this.canvasGroupRef.el;
if (group) group.appendChild(this._tempLine);
ev.preventDefault();
ev.stopPropagation();
return;
}
// Input port click — block drag/pan, do nothing
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
if (inHit) {
ev.stopPropagation();
return;
}
}
// If already connecting, ignore other interactions
if (this._connecting) return;
// ── 2. Node drag ──
const target = ev.target;
const nodeGroup = target.closest('.fd-node-group');
if (nodeGroup && ev.button === 0) {
const nodeId = nodeGroup.dataset.nodeId;
const nid = isNaN(nodeId) ? nodeId : parseInt(nodeId);
const node = this.state.nodes.find(n => n.id === nid || String(n.id) === nodeId);
if (node) {
this.state.selectedNodeId = node.id;
this.state.selectedConnectionId = null;
this.state.panelOpen = true;
this._dragging = {
nodeId: node.id,
startX: ev.clientX,
startY: ev.clientY,
origX: node.pos_x,
origY: node.pos_y,
};
ev.stopPropagation();
return;
}
}
// ── 3. Connection click ──
if (target.classList.contains('fd-connection-path') || target.classList.contains('fd-connection-hit')) {
const connId = target.dataset.connId;
if (connId) {
const cid = isNaN(connId) ? connId : parseInt(connId);
this.state.selectedConnectionId = cid;
this.state.selectedNodeId = null;
this.state.panelOpen = false;
ev.stopPropagation();
return;
}
}
// ── 4. Canvas pan ──
if (ev.button === 1 || (ev.button === 0 && !nodeGroup)) {
this.state.selectedNodeId = null;
this.state.selectedConnectionId = null;
this.state.panelOpen = false;
this._panning = {
startX: ev.clientX,
startY: ev.clientY,
origVX: this.state.viewX,
origVY: this.state.viewY,
};
ev.preventDefault();
}
}
_handleMouseMove(ev) {
if (this._dragging) {
const dx = (ev.clientX - this._dragging.startX) / this.state.zoom;
const dy = (ev.clientY - this._dragging.startY) / this.state.zoom;
const node = this.state.nodes.find(n => n.id === this._dragging.nodeId);
if (node) {
node.pos_x = snapToGrid(this._dragging.origX + dx);
node.pos_y = snapToGrid(this._dragging.origY + dy);
}
return;
}
if (this._panning) {
this.state.viewX = this._panning.origVX + (ev.clientX - this._panning.startX);
this.state.viewY = this._panning.origVY + (ev.clientY - this._panning.startY);
return;
}
if (this._connecting && this._tempLine) {
const pos = this.screenToCanvas(ev.clientX, ev.clientY);
this._tempLine.setAttribute('d',
bezierPath(this._connecting.startX, this._connecting.startY, pos.x, pos.y));
return;
}
// Port hover cursor — crosshair when near any port
const svg = this.svgRef.el;
if (svg) {
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
const hit = this._findPortNear(canvasPos.x, canvasPos.y, 'output')
|| this._findPortNear(canvasPos.x, canvasPos.y, 'input');
svg.style.cursor = hit ? 'crosshair' : '';
}
}
_handleMouseUp(ev) {
if (this._dragging) {
this.state.dirty = true;
this._dragging = null;
return;
}
if (this._panning) {
this._panning = null;
return;
}
if (this._connecting) {
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
if (inHit) {
const srcId = this._connecting.sourceNodeId;
const tgtId = inHit.node.id;
// Prevent self-connection
if (String(tgtId) !== String(srcId)) {
// Check for duplicate
const exists = this.state.connections.some(
c => c.source_node_id === srcId &&
c.target_node_id === tgtId &&
c.source_port === this._connecting.sourcePort
);
if (!exists) {
this.state.connections.push({
id: 'new_conn_' + Date.now(),
source_node_id: srcId,
target_node_id: tgtId,
source_port: this._connecting.sourcePort,
label: '',
condition_json: '{}',
sequence: 10,
});
this.state.dirty = true;
}
}
}
// Clean up temp line
if (this._tempLine && this._tempLine.parentNode) {
this._tempLine.parentNode.removeChild(this._tempLine);
}
this._tempLine = null;
this._connecting = null;
}
}
_handleWheel(ev) {
ev.preventDefault();
const delta = ev.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.state.zoom * delta));
// Zoom toward cursor position
const svg = this.svgRef.el;
const rect = svg.getBoundingClientRect();
const mx = ev.clientX - rect.left;
const my = ev.clientY - rect.top;
const scale = newZoom / this.state.zoom;
this.state.viewX = mx - (mx - this.state.viewX) * scale;
this.state.viewY = my - (my - this.state.viewY) * scale;
this.state.zoom = newZoom;
}
_handleKeyDown(ev) {
if (ev.key === 'Delete' || ev.key === 'Backspace') {
// Only if not typing in an input
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA' || ev.target.tagName === 'SELECT') return;
this.onDeleteSelected();
ev.preventDefault();
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault();
this.onSave();
}
}
/* ──────────────────────────────────────────────────────────────
Properties panel
────────────────────────────────────────────────────────────── */
get selectedNode() {
if (!this.state.selectedNodeId) return null;
return this.state.nodes.find(n => n.id === this.state.selectedNodeId) || null;
}
onPanelClose() {
this.state.panelOpen = false;
this.state.selectedNodeId = null;
}
onNodeFieldChange(ev) {
const node = this.selectedNode;
if (!node) return;
const field = ev.currentTarget.dataset.field;
const value = ev.currentTarget.value;
if (field && node.hasOwnProperty(field)) {
node[field] = value;
this.state.dirty = true;
}
}
onNodeNumberChange(ev) {
const node = this.selectedNode;
if (!node) return;
const field = ev.currentTarget.dataset.field;
const value = parseFloat(ev.currentTarget.value) || 0;
if (field) {
node[field] = value;
this.state.dirty = true;
}
}
/* ──────────────────────────────────────────────────────────────
Node option management (option_group)
────────────────────────────────────────────────────────────── */
onAddNodeOption() {
const node = this.selectedNode;
if (!node) return;
if (!node.node_options) node.node_options = [];
const seq = (node.node_options.length + 1) * 10;
node.node_options.push({
id: 'new_opt_' + Date.now(),
name: 'Option ' + (node.node_options.length + 1),
sequence: seq,
section_option_id: false,
enables_option_ids: [],
disables_option_ids: [],
requires_option_ids: [],
port_key: 'opt_' + seq,
});
this.state.dirty = true;
}
onRemoveNodeOption(ev) {
const node = this.selectedNode;
if (!node || !node.node_options) return;
const idx = parseInt(ev.currentTarget.dataset.index);
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
const removed = node.node_options.splice(idx, 1)[0];
// Also remove connections using this port
if (removed) {
this.state.connections = this.state.connections.filter(
c => !(c.source_node_id === node.id && c.source_port === removed.port_key)
);
}
this.state.dirty = true;
}
}
onNodeOptionNameChange(ev) {
const node = this.selectedNode;
if (!node || !node.node_options) return;
const idx = parseInt(ev.currentTarget.dataset.index);
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
node.node_options[idx].name = ev.currentTarget.value;
this.state.dirty = true;
}
}
}
registry.category("actions").add("fusion_flow_designer", FlowDesignerAction);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
/* ══════════════════════════════════════════════════════════════════
Flow Designer — Visual Configurator Styles
Uses Bootstrap 5.3 / Odoo CSS custom properties so light + dark
mode are handled automatically — zero manual overrides needed.
══════════════════════════════════════════════════════════════════ */
.fd-designer {
background: var(--bs-body-bg);
height: 100vh !important;
max-height: 100vh;
overflow: hidden;
}
/* ── Toolbar ── */
.fd-toolbar {
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
min-height: 48px;
z-index: 10;
flex-shrink: 0;
}
.fd-toolbar-title {
font-size: 1rem;
color: var(--bs-body-color);
max-width: 250px;
}
/* Measurement button — no inline styles, uses a custom class */
.fd-btn-measure {
border-color: var(--bs-border-color);
color: var(--bs-body-color);
&:hover,
&:focus {
background-color: var(--bs-tertiary-bg);
border-color: var(--bs-secondary-color);
color: var(--bs-emphasis-color);
}
}
/* ── SVG Canvas ── */
.fd-canvas-wrapper {
flex: 1;
min-height: 0;
}
.fd-svg-canvas {
width: 100%;
height: 100%;
background: var(--bs-secondary-bg);
cursor: grab;
&:active {
cursor: grabbing;
}
}
/* SVG grid dots — themed */
.fd-grid-dot {
fill: var(--bs-border-color);
}
/* ── Nodes ── */
.fd-node-group {
&:hover .fd-node-rect {
filter: brightness(0.97);
}
}
.fd-node-content {
user-select: none;
-webkit-user-select: none;
}
/* Node text adapts to theme */
.fd-node-name {
font-size: 12px;
font-weight: 600;
color: var(--bs-emphasis-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fd-node-detail {
font-size: 10px;
color: var(--bs-secondary-color);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Ports ── */
/* Visible decorative port circle — no pointer events */
.fd-port-visual {
stroke: var(--bs-body-bg);
stroke-width: 2;
pointer-events: none;
transition: r 0.15s ease, stroke-width 0.15s ease;
}
/* Invisible hit area — this is the actual click target */
.fd-port-hit {
fill: transparent;
stroke: none;
stroke-width: 0;
pointer-events: all;
cursor: crosshair;
/* Grow the visible sibling on hover via CSS ~ */
&:hover + .fd-port-visual {
r: 9;
stroke-width: 3;
}
}
/* Input port fill is now set directly as SVG attribute (#8b95a1) */
/* Port labels — concrete color; var() doesn't resolve reliably in SVG */
.fd-port-label {
fill: #8b95a1;
font-size: 9px;
pointer-events: none;
}
/* ── Connections ── */
/* Custom property so both light & dark themes get a visible stroke.
Defined on SVG so markers and all children inherit reliably. */
.fd-svg-canvas {
--fd-conn-stroke: #8b95a1;
--fd-conn-label-bg: rgba(55, 65, 81, 0.85); /* slate-700 @ 85 — visible on dark bg */
--fd-conn-label-color: #e5e7eb; /* gray-200 — bright text */
}
@media (prefers-color-scheme: light) {
.fd-svg-canvas {
--fd-conn-stroke: #9ca3af;
--fd-conn-label-bg: rgba(255, 255, 255, 0.9);
--fd-conn-label-color: #4b5563;
}
}
.fd-connection-path {
transition: stroke 0.15s ease, stroke-width 0.15s ease;
}
.fd-connection-temp {
stroke: var(--fd-conn-stroke);
stroke-width: 2.5;
stroke-dasharray: 8 4;
stroke-linecap: round;
fill: none;
pointer-events: none;
}
/* Label background pill */
.fd-conn-label-bg {
fill: var(--fd-conn-label-bg);
pointer-events: none;
}
.fd-conn-label {
fill: var(--fd-conn-label-color);
user-select: none;
pointer-events: none;
}
/* Arrow marker fills are set directly as SVG attributes
because CSS custom properties don't cascade into marker contexts */
/* ── Properties Panel ── */
.fd-properties-panel {
width: 320px;
min-width: 320px;
background: var(--bs-body-bg);
border-left: 1px solid var(--bs-border-color);
overflow-y: auto;
flex-shrink: 0;
z-index: 5;
display: flex;
flex-direction: column;
}
.fd-panel-header {
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
min-height: 42px;
flex-shrink: 0;
}
.fd-panel-body {
flex: 1;
overflow-y: auto;
}
.fd-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bs-secondary-color);
margin-bottom: 0.25rem;
}
/* Option list items */
.fd-option-row {
padding: 4px 0;
}
.fd-option-bullet {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Loading Overlay ── */
.fd-loading-overlay {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
z-index: 50;
}

View File

@@ -0,0 +1,415 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<!-- ══════════════════════════════════════════════════════════════════
FlowDesignerAction — Main layout
All colours use Bootstrap/Odoo CSS custom properties so
light + dark mode work automatically.
Node-type accent colours (green, red, amber…) are intentional
design tokens — they stay the same across themes.
══════════════════════════════════════════════════════════════════ -->
<t t-name="fusion_quotations.FlowDesignerAction">
<div class="fd-designer d-flex flex-column h-100">
<!-- ── Top Toolbar ── -->
<div class="fd-toolbar d-flex align-items-center gap-2 px-3 py-2">
<button class="btn btn-sm btn-outline-secondary" t-on-click="onBack">
<i class="fa fa-arrow-left me-1"/>Back
</button>
<div class="fd-toolbar-title fw-bold text-truncate ms-2" t-esc="state.flowName"/>
<span class="badge bg-secondary-subtle text-body-secondary ms-1" t-esc="state.equipmentType"/>
<div class="flex-grow-1"/>
<!-- Add Node Buttons -->
<div class="btn-group">
<button class="btn btn-sm btn-outline-success" data-node-type="start" t-on-click="onAddNode"
title="Add Start Node">
<i class="fa fa-play me-1"/>Start
</button>
<button class="btn btn-sm btn-outline-warning" data-node-type="decision" t-on-click="onAddNode"
title="Add Decision Node">
<i class="fa fa-code-fork me-1"/>Decision
</button>
<button class="btn btn-sm btn-outline-primary" data-node-type="option_group" t-on-click="onAddNode"
title="Add Option Group">
<i class="fa fa-list-ul me-1"/>Options
</button>
<button class="btn btn-sm btn-outline-info" data-node-type="action" t-on-click="onAddNode"
title="Add Action Node">
<i class="fa fa-bolt me-1"/>Action
</button>
<button class="btn btn-sm btn-outline-secondary fd-btn-measure"
data-node-type="measurement_check" t-on-click="onAddNode"
title="Add Measurement Check">
<i class="fa fa-tachometer me-1"/>Measure
</button>
<button class="btn btn-sm btn-outline-danger" data-node-type="end" t-on-click="onAddNode"
title="Add End Node">
<i class="fa fa-stop me-1"/>End
</button>
</div>
<div class="vr mx-1"/>
<!-- Zoom Controls -->
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" t-on-click="onZoomOut" title="Zoom Out">
<i class="fa fa-search-minus"/>
</button>
<button class="btn btn-outline-secondary" t-on-click="onZoomReset" title="Reset Zoom"
style="min-width:55px;">
<t t-esc="Math.round(state.zoom * 100)"/>%
</button>
<button class="btn btn-outline-secondary" t-on-click="onZoomIn" title="Zoom In">
<i class="fa fa-search-plus"/>
</button>
</div>
<div class="vr mx-1"/>
<button class="btn btn-sm btn-outline-danger" t-on-click="onDeleteSelected"
t-att-disabled="!state.selectedNodeId and !state.selectedConnectionId"
title="Delete Selected (Del)">
<i class="fa fa-trash"/>
</button>
<button class="btn btn-sm btn-primary" t-on-click="onSave"
t-att-disabled="state.saving or !state.dirty">
<i class="fa fa-save me-1"/>
<t t-if="state.saving">Saving...</t>
<t t-else="">Save</t>
</button>
<t t-if="state.dirty">
<span class="badge bg-warning text-dark ms-1">Unsaved</span>
</t>
</div>
<!-- ── Canvas + Panel ── -->
<div class="fd-canvas-wrapper d-flex flex-grow-1 overflow-hidden position-relative">
<!-- SVG Canvas -->
<svg class="fd-svg-canvas flex-grow-1" t-ref="svgCanvas" xmlns="http://www.w3.org/2000/svg">
<!-- Grid Pattern -->
<defs>
<pattern id="fd-grid" width="20" height="20" patternUnits="userSpaceOnUse"
t-att-x="state.viewX" t-att-y="state.viewY"
t-att-patternTransform="'scale(' + state.zoom + ')'">
<circle cx="10" cy="10" r="1" class="fd-grid-dot"/>
</pattern>
<!-- Arrow markers — fill set as attribute because CSS custom
properties don't cascade into SVG marker rendering contexts -->
<marker id="fd-arrow" viewBox="0 0 10 6" refX="10" refY="3"
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,3 L0,6 z" fill="#8b95a1"/>
</marker>
<marker id="fd-arrow-selected" viewBox="0 0 10 6" refX="10" refY="3"
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,3 L0,6 z" fill="var(--bs-primary, #3b82f6)"/>
</marker>
</defs>
<rect width="100%" height="100%" fill="url(#fd-grid)"/>
<!-- Transform group for zoom/pan -->
<g t-ref="canvasGroup" class="fd-canvas-group" t-att-transform="transformStr">
<!-- Connections -->
<t t-foreach="connectionPaths" t-as="conn" t-key="conn.id">
<!-- Invisible fat hit area for click detection -->
<path t-att-d="conn.path" fill="none" stroke="transparent" stroke-width="14"
class="fd-connection-hit" t-att-data-conn-id="conn.id"
style="cursor:pointer;"/>
<!-- Visible bezier line — stroke set as SVG attr for guaranteed rendering -->
<path t-att-d="conn.path"
class="fd-connection-path"
t-att-stroke="conn.isSelected ? 'var(--bs-primary)' : 'var(--fd-conn-stroke, #8b95a1)'"
t-att-stroke-width="conn.isSelected ? '3.5' : '2.5'"
fill="none"
stroke-linecap="round"
t-att-marker-end="conn.isSelected ? 'url(#fd-arrow-selected)' : 'url(#fd-arrow)'"
t-att-data-conn-id="conn.id"
style="pointer-events:none;"/>
<!-- Connection label with background pill -->
<t t-if="conn.label">
<rect t-att-x="conn.midX - conn.labelW / 2"
t-att-y="conn.midY - 18"
t-att-width="conn.labelW" height="20" rx="10"
class="fd-conn-label-bg"/>
<text t-att-x="conn.midX" t-att-y="conn.midY - 5"
text-anchor="middle" font-size="11" font-weight="600"
class="fd-conn-label">
<t t-esc="conn.label"/>
</text>
</t>
</t>
<!-- Nodes -->
<t t-foreach="nodesWithPorts" t-as="node" t-key="node.id">
<g class="fd-node-group" t-att-data-node-id="node.id"
t-att-transform="'translate(' + node.pos_x + ',' + node.pos_y + ')'"
style="cursor:grab;">
<!-- Node body — accent color fill is a design token, stays fixed -->
<rect x="0" y="0" t-att-width="node.width" t-att-height="node.height"
t-att-rx="node.node_type === 'start' or node.node_type === 'end' ? node.height / 2 : 8"
t-att-fill="node.meta.color + '18'"
t-att-stroke="node.isSelected ? 'var(--bs-primary)' : node.meta.color"
t-att-stroke-width="node.isSelected ? '3' : '2'"
class="fd-node-rect"/>
<!-- Icon + Name — pointer-events:none so clicks pass through to ports/rect -->
<foreignObject x="0" y="0" t-att-width="node.width" t-att-height="node.height"
style="pointer-events:none;">
<div xmlns="http://www.w3.org/1999/xhtml" class="fd-node-content"
t-att-style="'display:flex;flex-direction:column;justify-content:center;height:' + node.height + 'px;padding:0 14px;overflow:hidden;pointer-events:none;'">
<div style="display:flex;align-items:center;gap:6px;">
<i t-att-class="'fa ' + (node.icon || 'fa-circle')"
t-att-style="'color:' + node.meta.color + ';font-size:14px;flex-shrink:0;'"/>
<span class="fd-node-name" t-esc="node.name"/>
</div>
<t t-if="node.node_type === 'decision' and node.decision_field">
<div class="fd-node-detail">
<t t-esc="node.decision_field"/>
<t t-if="node.decision_operator"> <t t-esc="node.decision_operator"/> </t>
<t t-if="node.decision_value"> <t t-esc="node.decision_value"/></t>
</div>
</t>
<t t-if="node.node_type === 'action' and node.action_type">
<div class="fd-node-detail">
<t t-esc="node.action_type"/>
</div>
</t>
<t t-if="node.node_type === 'measurement_check' and node.measurement_field">
<div class="fd-node-detail">
<t t-esc="node.measurement_field"/>
<t t-if="node.comparison"> <t t-esc="node.comparison"/> </t>
<t t-if="node.threshold_value"> <t t-esc="node.threshold_value"/></t>
</div>
</t>
<t t-if="node.section_name">
<div class="fd-node-detail">
<i class="fa fa-folder-o me-1"/><t t-esc="node.section_name"/>
</div>
</t>
</div>
</foreignObject>
<!-- Ports -->
<t t-foreach="node.ports" t-as="port" t-key="port.key">
<!-- Invisible hit area — detected by elementsFromPoint in JS -->
<circle t-att-cx="port.x" t-att-cy="port.y" r="15"
fill="transparent" stroke="none"
pointer-events="all"
class="fd-port-hit fd-port"
t-att-data-port-key="port.key"
t-att-data-port-type="port.type"
t-att-data-node-id="'' + node.id"
style="cursor:crosshair;"/>
<!-- Visible port circle — concrete fill for both types -->
<circle t-att-cx="port.x" t-att-cy="port.y" r="7"
t-att-fill="port.type !== 'input' ? node.meta.color : '#8b95a1'"
class="fd-port-visual"
style="pointer-events:none;"/>
<t t-if="port.label">
<text t-att-x="port.x + (port.type === 'output' ? 12 : -12)"
t-att-y="port.y + 4"
t-att-text-anchor="port.type === 'output' ? 'start' : 'end'"
class="fd-port-label"
fill="#8b95a1">
<t t-esc="port.label"/>
</text>
</t>
</t>
</g>
</t>
</g>
</svg>
<!-- ── Properties Panel (right side) ── -->
<t t-if="state.panelOpen and selectedNode">
<div class="fd-properties-panel">
<div class="fd-panel-header d-flex align-items-center justify-content-between px-3 py-2">
<div class="d-flex align-items-center gap-2">
<i t-att-class="'fa ' + (selectedNode.icon || 'fa-circle')"
t-att-style="'color:' + (selectedNode.color || '#3b82f6')"/>
<span class="fw-bold" t-esc="getNodeTypeLabel(selectedNode.node_type)"/>
</div>
<button class="btn btn-sm btn-link text-body-secondary p-0" t-on-click="onPanelClose">
<i class="fa fa-times"/>
</button>
</div>
<div class="fd-panel-body p-3">
<!-- Common: Name -->
<div class="mb-3">
<label class="form-label fd-label">Name</label>
<input type="text" class="form-control form-control-sm"
data-field="name" t-att-value="selectedNode.name"
t-on-change="onNodeFieldChange"/>
</div>
<!-- Decision Fields -->
<t t-if="selectedNode.node_type === 'decision'">
<div class="mb-3">
<label class="form-label fd-label">Decision Field</label>
<select class="form-select form-select-sm" data-field="decision_field"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="equipment_type" t-att-selected="selectedNode.decision_field === 'equipment_type'">Equipment Type</option>
<option value="wheelchair_type" t-att-selected="selectedNode.decision_field === 'wheelchair_type'">Wheelchair Category</option>
<option value="powerchair_type" t-att-selected="selectedNode.decision_field === 'powerchair_type'">Power Chair Category</option>
<option value="build_type" t-att-selected="selectedNode.decision_field === 'build_type'">Build Type</option>
<option value="client_type" t-att-selected="selectedNode.decision_field === 'client_type'">Client Type</option>
<option value="reason_for_application" t-att-selected="selectedNode.decision_field === 'reason_for_application'">Reason for Application</option>
<option value="seat_width" t-att-selected="selectedNode.decision_field === 'seat_width'">Seat Width</option>
<option value="seat_depth" t-att-selected="selectedNode.decision_field === 'seat_depth'">Seat Depth</option>
<option value="client_weight" t-att-selected="selectedNode.decision_field === 'client_weight'">Client Weight</option>
<option value="back_height" t-att-selected="selectedNode.decision_field === 'back_height'">Back Height</option>
<option value="seat_to_floor" t-att-selected="selectedNode.decision_field === 'seat_to_floor'">Seat to Floor</option>
<option value="leg_rest_length" t-att-selected="selectedNode.decision_field === 'leg_rest_length'">Leg Rest Length</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Operator</label>
<select class="form-select form-select-sm" data-field="decision_operator"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="eq" t-att-selected="selectedNode.decision_operator === 'eq'">=</option>
<option value="neq" t-att-selected="selectedNode.decision_operator === 'neq'"></option>
<option value="gt" t-att-selected="selectedNode.decision_operator === 'gt'">&gt;</option>
<option value="gte" t-att-selected="selectedNode.decision_operator === 'gte'"></option>
<option value="lt" t-att-selected="selectedNode.decision_operator === 'lt'">&lt;</option>
<option value="lte" t-att-selected="selectedNode.decision_operator === 'lte'"></option>
<option value="in" t-att-selected="selectedNode.decision_operator === 'in'">In List</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Expected Value</label>
<input type="text" class="form-control form-control-sm"
data-field="decision_value" t-att-value="selectedNode.decision_value"
t-on-change="onNodeFieldChange"
placeholder="For 'In List' use comma-separated"/>
</div>
</t>
<!-- Measurement Check Fields -->
<t t-if="selectedNode.node_type === 'measurement_check'">
<div class="mb-3">
<label class="form-label fd-label">Measurement</label>
<select class="form-select form-select-sm" data-field="measurement_field"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="seat_width" t-att-selected="selectedNode.measurement_field === 'seat_width'">Seat Width</option>
<option value="seat_depth" t-att-selected="selectedNode.measurement_field === 'seat_depth'">Seat Depth</option>
<option value="back_width" t-att-selected="selectedNode.measurement_field === 'back_width'">Backrest Width</option>
<option value="back_height" t-att-selected="selectedNode.measurement_field === 'back_height'">Back Height</option>
<option value="seat_to_floor" t-att-selected="selectedNode.measurement_field === 'seat_to_floor'">Seat to Floor</option>
<option value="leg_rest_length" t-att-selected="selectedNode.measurement_field === 'leg_rest_length'">Leg Rest Length</option>
<option value="client_weight" t-att-selected="selectedNode.measurement_field === 'client_weight'">Client Weight</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Comparison</label>
<select class="form-select form-select-sm" data-field="comparison"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="gt" t-att-selected="selectedNode.comparison === 'gt'">Greater Than</option>
<option value="gte" t-att-selected="selectedNode.comparison === 'gte'">Greater Than or Equal</option>
<option value="lt" t-att-selected="selectedNode.comparison === 'lt'">Less Than</option>
<option value="eq" t-att-selected="selectedNode.comparison === 'eq'">Equal To</option>
<option value="neq" t-att-selected="selectedNode.comparison === 'neq'">Not Equal To</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fd-label">Threshold</label>
<input type="number" class="form-control form-control-sm"
data-field="threshold_value"
t-att-value="selectedNode.threshold_value"
t-on-change="onNodeNumberChange" step="0.1"/>
</div>
</t>
<!-- Action Fields -->
<t t-if="selectedNode.node_type === 'action'">
<div class="mb-3">
<label class="form-label fd-label">Action Type</label>
<select class="form-select form-select-sm" data-field="action_type"
t-on-change="onNodeFieldChange">
<option value="">-- Select --</option>
<option value="enable" t-att-selected="selectedNode.action_type === 'enable'">Enable Options</option>
<option value="disable" t-att-selected="selectedNode.action_type === 'disable'">Disable Options</option>
<option value="require" t-att-selected="selectedNode.action_type === 'require'">Require Options</option>
<option value="skip_step" t-att-selected="selectedNode.action_type === 'skip_step'">Skip Portal Step</option>
<option value="set_value" t-att-selected="selectedNode.action_type === 'set_value'">Set Field Value</option>
</select>
</div>
<t t-if="selectedNode.action_type === 'skip_step'">
<div class="mb-3">
<label class="form-label fd-label">Target Step</label>
<input type="number" class="form-control form-control-sm"
data-field="target_step"
t-att-value="selectedNode.target_step"
t-on-change="onNodeNumberChange" min="1" max="10"/>
</div>
</t>
</t>
<!-- Option Group — options list -->
<t t-if="selectedNode.node_type === 'option_group'">
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label fd-label mb-0">Options</label>
<button class="btn btn-sm btn-outline-primary" t-on-click="onAddNodeOption">
<i class="fa fa-plus me-1"/>Add
</button>
</div>
<t t-if="selectedNode.node_options and selectedNode.node_options.length">
<t t-foreach="selectedNode.node_options" t-as="opt" t-key="opt.id">
<div class="fd-option-row d-flex align-items-center gap-2 mb-2">
<span class="fd-option-bullet" t-att-style="'background:' + (selectedNode.color || '#3b82f6')"/>
<input type="text" class="form-control form-control-sm flex-grow-1"
t-att-value="opt.name"
t-att-data-index="opt_index"
t-on-change="onNodeOptionNameChange"/>
<button class="btn btn-sm btn-link text-danger p-0"
t-att-data-index="opt_index"
t-on-click="onRemoveNodeOption">
<i class="fa fa-trash-o"/>
</button>
</div>
</t>
</t>
<t t-else="">
<div class="text-body-secondary small fst-italic">No options yet. Click "Add" to create one.</div>
</t>
</div>
</t>
<!-- Node info footer -->
<div class="mt-4 pt-3 border-top">
<div class="text-body-secondary small">
<div><strong>ID:</strong> <t t-esc="selectedNode.id"/></div>
<div><strong>Position:</strong> (<t t-esc="Math.round(selectedNode.pos_x)"/>, <t t-esc="Math.round(selectedNode.pos_y)"/>)</div>
</div>
</div>
</div>
</div>
</t>
<!-- Loading overlay -->
<t t-if="state.loading">
<div class="fd-loading-overlay d-flex align-items-center justify-content-center">
<div class="text-center">
<i class="fa fa-spinner fa-spin fa-2x text-primary mb-2"/>
<div class="text-body-secondary">Loading flow...</div>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================
Equipment Type — Tree View
============================================================ -->
<record id="view_equipment_type_tree" model="ir.ui.view">
<field name="name">fusion.equipment.type.tree</field>
<field name="model">fusion.equipment.type</field>
<field name="arch" type="xml">
<list string="Equipment Types" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="icon"/>
<field name="active" column_invisible="1"/>
</list>
</field>
</record>
<!-- ============================================================
Equipment Type — Form View
============================================================ -->
<record id="view_equipment_type_form" model="ir.ui.view">
<field name="name">fusion.equipment.type.form</field>
<field name="model">fusion.equipment.type</field>
<field name="arch" type="xml">
<form string="Equipment Type">
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. Stair Lift"/>
</h1>
</div>
<group>
<group>
<field name="code" placeholder="e.g. stair_lift"/>
<field name="icon" placeholder="e.g. fa-arrow-up"/>
<field name="sequence"/>
</group>
<group>
<field name="active"/>
</group>
</group>
<group string="Description">
<field name="description" nolabel="1"
placeholder="Optional description of this equipment type..."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============================================================
Equipment Type — Action
============================================================ -->
<record id="action_equipment_type" model="ir.actions.act_window">
<field name="name">Equipment Types</field>
<field name="res_model">fusion.equipment.type</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create an Equipment Type
</p>
<p>
Equipment types define the categories of equipment that can be
assessed and quoted. Each type can have its own Configuration Flow
with custom form steps.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top menu under Fusion Claims -->
<menuitem id="menu_wc_quotation_root"
name="Quotation Builder"
parent="fusion_claims.menu_adp_claims_root"
sequence="25"/>
<!-- Assessments -->
<menuitem id="menu_wc_assessment"
name="Wheelchair Assessments"
parent="menu_wc_quotation_root"
action="action_wc_assessment"
sequence="10"/>
<!-- Configuration submenu -->
<menuitem id="menu_wc_config"
name="Configuration"
parent="menu_wc_quotation_root"
sequence="90"
groups="sales_team.group_sale_manager"/>
<menuitem id="menu_wc_sections"
name="Wheelchair Sections"
parent="menu_wc_config"
action="action_wc_section"
sequence="10"/>
<menuitem id="menu_wc_upcharge_rules"
name="Upcharge Rules"
parent="menu_wc_config"
action="action_wc_upcharge_rule"
sequence="20"/>
<menuitem id="menu_wc_config_flows"
name="Configuration Flows"
parent="menu_wc_config"
action="action_wc_config_flow"
sequence="30"/>
<menuitem id="menu_equipment_types"
name="Equipment Types"
parent="menu_wc_config"
action="action_equipment_type"
sequence="40"/>
<!-- Server action: Auto-Populate All Sections -->
<record id="action_auto_populate_all" model="ir.actions.server">
<field name="name">Auto-Populate All Sections from Inventory</field>
<field name="model_id" ref="model_fusion_wc_section"/>
<field name="state">code</field>
<field name="code">action = model.action_auto_populate_all_sections()</field>
</record>
<menuitem id="menu_wc_auto_populate"
name="Auto-Populate Products"
parent="menu_wc_config"
action="action_auto_populate_all"
sequence="5"/>
</odoo>

View File

@@ -0,0 +1,990 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- ASSESSMENT LIST PAGE -->
<!-- ============================================================ -->
<template id="portal_quotation_list" name="Equipment Assessments List">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="o_portal_my_home">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3>Equipment Assessments</h3>
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa fa-plus me-1"/> New Assessment
</button>
<ul class="dropdown-menu dropdown-menu-end">
<t t-foreach="equipment_types" t-as="etype">
<li>
<a class="dropdown-item"
t-attf-href="/my/quotation/builder/new?equipment_type=#{etype.code}">
<i t-attf-class="fa #{etype.icon or 'fa-cog'} me-2"/>
<t t-out="etype.name"/>
</a>
</li>
</t>
</ul>
</div>
</div>
<t t-if="not assessments">
<div class="alert alert-info text-center">
No assessments yet. Click "New Assessment" to start.
</div>
</t>
<t t-if="assessments">
<table class="table table-hover">
<thead>
<tr>
<th>Reference</th>
<th>Client</th>
<th>Equipment</th>
<th>Date</th>
<th>Status</th>
<th>Total</th>
<th/>
</tr>
</thead>
<tbody>
<t t-foreach="assessments" t-as="a">
<tr>
<td><t t-out="a.reference"/></td>
<td><t t-out="a.client_name or 'N/A'"/></td>
<td><t t-out="equip_type_map.get(a.equipment_type, a.equipment_type or '')"/></td>
<td><t t-out="a.assessment_date" t-options="{'widget': 'date'}"/></td>
<td>
<span t-attf-class="badge #{
'text-bg-info' if a.state == 'draft' else
'text-bg-warning' if a.state == 'review' else
'text-bg-success' if a.state == 'quoted' else
'text-bg-secondary'
}">
<t t-out="dict(a._fields['state'].selection).get(a.state, '')"/>
</span>
</td>
<td>$<t t-out="'%.2f' % a.total_estimate"/></td>
<td>
<a t-attf-href="/my/quotation/builder/#{a.id}/edit"
class="btn btn-sm btn-outline-primary">
<t t-if="a.state == 'draft'">Continue</t>
<t t-else="">View</t>
</a>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- MULTI-STEP ASSESSMENT FORM (Dynamic) -->
<!-- ============================================================ -->
<template id="portal_quotation_form" name="Equipment Assessment Form">
<t t-call="portal.portal_layout">
<t t-set="additional_title">Equipment Assessment</t>
<div class="wc-assessment-form" id="wcAssessmentForm">
<!-- Success/Error Messages -->
<t t-if="request.params.get('success') == 'saved'">
<div class="alert alert-success alert-dismissible fade show">
Assessment saved successfully.
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<t t-if="request.params.get('success') == 'quotation_generated'">
<div class="alert alert-success alert-dismissible fade show">
<i class="fa fa-check-circle me-1"/> Quotation generated successfully!
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<t t-if="request.params.get('error')">
<div class="alert alert-danger alert-dismissible fade show">
<t t-out="request.params.get('error')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- ===== DYNAMIC Step Indicator ===== -->
<div class="wc-steps mb-4">
<div class="d-flex justify-content-between">
<t t-foreach="flow_steps" t-as="fstep">
<div t-attf-class="wc-step-indicator text-center flex-fill #{'active' if fstep_index == 0 else ''}"
t-att-data-step="fstep_index + 1">
<div class="wc-step-number rounded-circle d-inline-flex align-items-center justify-content-center">
<t t-out="fstep_index + 1"/>
</div>
<div class="wc-step-label small mt-1">
<t t-out="fstep.name"/>
</div>
</div>
</t>
</div>
</div>
<!-- Open in Backend link — only for internal users -->
<t t-if="assessment and request.env.user.has_group('base.group_user')">
<div class="text-end mb-2">
<a t-attf-href="/odoo/fusion-quotation-builder/#{assessment.id}"
class="btn btn-sm btn-outline-secondary" target="_blank">
<i class="fa fa-pencil me-1"/> Open in Backend
</a>
</div>
</t>
<form method="post"
t-att-action="'/quotation/form/' + access_token + '/save'
if is_public else '/my/quotation/builder/save'"
id="wcForm" class="wc-form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="assessment_id"
t-att-value="assessment.id if assessment else 0"/>
<input type="hidden" name="current_step" id="currentStep" value="1"/>
<input type="hidden" name="action" id="formAction" value="save"/>
<!-- Store access token for JS to detect public mode -->
<t t-if="is_public">
<input type="hidden" name="access_token" id="accessToken"
t-att-value="access_token"/>
</t>
<!-- Hidden fields for line data (populated by JS) -->
<input type="hidden" name="line_product_ids" id="lineProductIds" value=""/>
<input type="hidden" name="line_section_ids" id="lineSectionIds" value=""/>
<input type="hidden" name="line_build_types" id="lineBuildTypes" value=""/>
<input type="hidden" name="line_quantities" id="lineQuantities" value=""/>
<input type="hidden" name="line_rationales" id="lineRationales" value=""/>
<!-- ===== DYNAMIC Step Content ===== -->
<t t-foreach="flow_steps" t-as="fstep">
<div t-attf-class="wc-step #{'d-none' if fstep_index > 0 else ''}"
t-att-data-step="fstep_index + 1"
t-att-data-step-type="fstep.step_type">
<t t-if="fstep.step_type == 'client_info'"
t-call="fusion_quotations.fq_step_client"/>
<t t-if="fstep.step_type == 'measurements'"
t-call="fusion_quotations.fq_step_measurements"/>
<t t-if="fstep.step_type == 'product_select'"
t-call="fusion_quotations.fq_step_product_select"/>
<t t-if="fstep.step_type == 'options'"
t-call="fusion_quotations.fq_step_options"/>
<t t-if="fstep.step_type == 'review'"
t-call="fusion_quotations.fq_step_review"/>
<t t-if="fstep.step_type == 'custom'"
t-call="fusion_quotations.fq_step_custom"/>
</div>
</t>
<!-- ============================================ -->
<!-- NAVIGATION BUTTONS -->
<!-- ============================================ -->
<div class="d-flex justify-content-between mt-4 pt-3 border-top">
<button type="button" class="btn btn-outline-secondary" id="btnPrev"
style="display:none;">
<i class="fa fa-arrow-left me-1"/> Previous
</button>
<div class="ms-auto d-flex gap-2">
<button type="submit" class="btn btn-outline-primary" id="btnSave"
name="action" value="save">
<i class="fa fa-save me-1"/> Save Draft
</button>
<button type="button" class="btn btn-primary" id="btnNext">
Next <i class="fa fa-arrow-right ms-1"/>
</button>
<button type="submit" class="btn btn-success d-none" id="btnGenerate"
name="action" value="generate">
<i class="fa fa-magic me-1"/> Generate Quotation
</button>
</div>
</div>
</form>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- SUB-TEMPLATE: Client &amp; Equipment (step_type=client_info) -->
<!-- ============================================================ -->
<template id="fq_step_client" name="Step: Client &amp; Equipment">
<!--
Config-driven client step. The step's fields_json controls which
optional field groups are shown. Example fields_json:
{
"show_health_card": true,
"show_dob": true,
"show_adp_fields": true,
"show_wheelchair_category": true,
"show_powerchair_category": false
}
Steps with no fields_json (or empty {}) show only the base fields:
name, contact, address, and equipment type selector.
-->
<t t-set="client_config" t-value="step_fields.get(fstep.id, {})"/>
<h4 class="mb-3">Client &amp; Equipment</h4>
<div class="row g-3">
<!-- ── LEFT COLUMN: Client Info ── -->
<div class="col-md-6">
<div class="card wc-section-card h-100">
<div class="card-header"><h5 class="mb-0"><i class="fa fa-user me-2"/>Client</h5></div>
<div class="card-body">
<div class="mb-3 wc-search-container" style="position:relative;z-index:100;">
<label class="form-label fw-bold">Search Existing Client</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-search"/></span>
<input type="text" class="form-control" id="clientSearch"
placeholder="Type name, phone, or health card..."
autocomplete="off"/>
</div>
<div id="clientSearchResults" class="list-group d-none"
style="position:absolute;z-index:1060;width:100%;max-height:300px;overflow-y:auto;background:var(--bs-body-bg);box-shadow:0 8px 30px rgba(0,0,0,.18);border:1px solid var(--bs-border-color);border-top:none;border-radius:0 0 .5rem .5rem;"/>
<input type="hidden" name="partner_id" id="partnerId"
t-att-value="assessment.partner_id.id if assessment and assessment.partner_id else 0"/>
</div>
<div id="selectedClient" class="alert alert-info d-none mb-3">
<strong id="selectedClientName"/>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2"
id="clearClient">Change</button>
</div>
<div id="newClientFields">
<div class="row g-2 mb-2">
<div class="col">
<input type="text" name="client_first_name" class="form-control"
placeholder="First Name"
t-att-value="assessment.client_first_name if assessment else ''"/>
</div>
<div class="col">
<input type="text" name="client_last_name" class="form-control"
placeholder="Last Name"
t-att-value="assessment.client_last_name if assessment else ''"/>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col">
<input type="tel" name="client_phone" class="form-control"
placeholder="Phone"
t-att-value="assessment.client_phone if assessment else ''"/>
</div>
<div class="col">
<input type="email" name="client_email" class="form-control"
placeholder="Email"
t-att-value="assessment.client_email if assessment else ''"/>
</div>
</div>
<input type="text" name="client_street" class="form-control mb-2"
placeholder="Street Address"
t-att-value="assessment.client_street if assessment else ''"/>
<div class="row g-2 mb-2">
<div class="col">
<input type="text" name="client_city" class="form-control"
placeholder="City"
t-att-value="assessment.client_city if assessment else ''"/>
</div>
<div class="col">
<select name="client_state_id" class="form-select">
<option value="">Province</option>
<t t-foreach="provinces" t-as="prov">
<option t-att-value="prov.id"
t-att-selected="assessment and assessment.client_state_id.id == prov.id">
<t t-out="prov.name"/>
</option>
</t>
</select>
</div>
<div class="col-3">
<input type="text" name="client_zip" class="form-control"
placeholder="Postal Code"
t-att-value="assessment.client_zip if assessment else ''"/>
</div>
</div>
</div>
<!-- Health Card — controlled by step config -->
<t t-if="client_config.get('show_health_card') or client_config.get('show_dob')">
<div class="row g-2 mb-2">
<t t-if="client_config.get('show_health_card')">
<div class="col">
<label class="form-label">Health Card</label>
<input type="text" name="client_health_card" class="form-control"
t-att-value="assessment.client_health_card if assessment else ''"/>
</div>
</t>
<t t-if="client_config.get('show_dob')">
<div class="col">
<label class="form-label">Date of Birth</label>
<input type="date" name="client_dob" class="form-control"
t-att-value="assessment.client_dob if assessment else ''"/>
</div>
</t>
</div>
</t>
</div><!-- /card-body -->
</div><!-- /card -->
</div>
<!-- ── RIGHT COLUMN: Equipment Config ── -->
<div class="col-md-6">
<div class="card wc-section-card h-100">
<div class="card-header"><h5 class="mb-0"><i class="fa fa-cogs me-2"/>Equipment</h5></div>
<div class="card-body">
<!-- Equipment type — show current type as label + hidden field -->
<div class="mb-3">
<label class="form-label fw-bold">Equipment Type</label>
<div class="d-flex align-items-center gap-2">
<t t-foreach="equipment_types" t-as="etype">
<t t-if="etype.code == equipment_type">
<span class="badge bg-primary fs-6 py-2 px-3">
<i t-attf-class="fa #{etype.icon or 'fa-cog'} me-1"/> <t t-out="etype.name"/>
</span>
</t>
</t>
<input type="hidden" name="equipment_type" t-att-value="equipment_type"/>
</div>
</div>
<!-- Wheelchair category — controlled by step config -->
<t t-if="client_config.get('show_wheelchair_category')">
<div class="mb-3" id="wheelchairTypeGroup">
<label class="form-label">Wheelchair Category</label>
<select name="wheelchair_type" class="form-select">
<option value="">Select...</option>
<option value="type_1" t-att-selected="assessment and assessment.wheelchair_type == 'type_1'">Type 1 - Standard</option>
<option value="type_2" t-att-selected="assessment and assessment.wheelchair_type == 'type_2'">Type 2 - Lightweight</option>
<option value="type_3" t-att-selected="assessment and assessment.wheelchair_type == 'type_3'">Type 3 - Ultra Lightweight</option>
<option value="type_4" t-att-selected="assessment and assessment.wheelchair_type == 'type_4'">Type 4 - Rigid Frame</option>
<option value="type_5" t-att-selected="assessment and assessment.wheelchair_type == 'type_5'">Type 5 - Dynamic Tilt</option>
</select>
</div>
</t>
<!-- Powerchair category — controlled by step config -->
<t t-if="client_config.get('show_powerchair_category')">
<div class="mb-3" id="powerchairTypeGroup">
<label class="form-label">Powerchair Category</label>
<select name="powerchair_type" class="form-select">
<option value="">Select...</option>
<option value="type_1" t-att-selected="assessment and assessment.powerchair_type == 'type_1'">Power Base Type 1</option>
<option value="type_2" t-att-selected="assessment and assessment.powerchair_type == 'type_2'">Power Base Type 2</option>
<option value="type_3" t-att-selected="assessment and assessment.powerchair_type == 'type_3'">Power Base Type 3</option>
</select>
</div>
</t>
<!-- ADP fields (client type + reason) — controlled by step config -->
<t t-if="client_config.get('show_adp_fields')">
<div class="mb-3">
<label class="form-label">Client Type</label>
<select name="client_type" class="form-select">
<option value="reg" t-att-selected="not assessment or assessment.client_type == 'reg'">REG - Regular ADP (75/25)</option>
<option value="ods" t-att-selected="assessment and assessment.client_type == 'ods'">ODS - ODSP (100% ADP)</option>
<option value="acs" t-att-selected="assessment and assessment.client_type == 'acs'">ACS - ACSD (100% ADP)</option>
<option value="owp" t-att-selected="assessment and assessment.client_type == 'owp'">OWP - Ontario Works (100% ADP)</option>
</select>
</div>
<input type="hidden" name="build_type" value="modular"/>
<div class="mb-3">
<label class="form-label">Reason for Application</label>
<select name="reason_for_application" class="form-select">
<option value="">Select...</option>
<option value="first_access" t-att-selected="assessment and assessment.reason_for_application == 'first_access'">First Access</option>
<option value="additions" t-att-selected="assessment and assessment.reason_for_application == 'additions'">Additions</option>
<option value="modifications" t-att-selected="assessment and assessment.reason_for_application == 'modifications'">Modifications</option>
<option value="replacements" t-att-selected="assessment and assessment.reason_for_application == 'replacements'">Replacements</option>
</select>
</div>
</t>
</div><!-- /card-body -->
</div><!-- /card -->
</div>
</div>
</template>
<!-- ============================================================ -->
<!-- SUB-TEMPLATE: Measurements (step_type=measurements) -->
<!-- ============================================================ -->
<template id="fq_step_measurements" name="Step: Measurements">
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
<t t-if="fstep.help_text">
<p class="text-muted"><t t-out="fstep.help_text"/></p>
</t>
<t t-if="not fstep.help_text">
<p class="text-muted">All measurements are required for the ADP application.</p>
</t>
<!-- Dynamic fields from fields_json -->
<t t-set="mfields" t-value="step_fields.get(fstep.id, [])"/>
<t t-if="mfields">
<div class="row g-3">
<t t-foreach="mfields" t-as="mf">
<div class="col-md-6">
<div class="wc-measurement-field p-3 border rounded">
<label class="form-label fw-bold" t-out="mf.get('label', '')"/>
<t t-if="mf.get('type') == 'selection'">
<select t-att-name="mf.get('name')" class="form-select"
t-att-required="mf.get('required')">
<option value="">Select...</option>
<t t-foreach="mf.get('options', [])" t-as="opt">
<option t-att-value="opt[0]"
t-att-selected="field_values.get(mf.get('name')) == str(opt[0])"
t-out="opt[1]"/>
</t>
</select>
</t>
<t t-if="mf.get('type') != 'selection'">
<div class="d-flex gap-2 align-items-center">
<input t-att-type="'number' if mf.get('type') in ('float', 'integer') else 'text'"
t-att-step="mf.get('step', '0.25') if mf.get('type') == 'float' else ('1' if mf.get('type') == 'integer' else '')"
t-att-name="mf.get('name')"
class="form-control wc-measurement-input"
t-att-data-upcharge-field="mf.get('name')"
t-att-value="field_values.get(mf.get('name'), '')"
t-att-required="mf.get('required')"/>
<t t-if="mf.get('units')">
<select t-att-name="mf.get('unit_field', mf.get('name') + '_unit')"
class="form-select wc-unit-select" style="width:100px;">
<t t-foreach="mf.get('units', [])" t-as="unit">
<option t-att-value="unit"
t-att-selected="field_values.get(mf.get('unit_field', mf.get('name') + '_unit')) == unit"
t-out="unit"/>
</t>
</select>
</t>
<t t-if="mf.get('unit') and not mf.get('units')">
<span class="input-group-text" t-out="mf.get('unit')"/>
</t>
<span class="wc-upcharge-badge d-none"/>
</div>
</t>
</div>
</div>
</t>
</div>
</t>
<!-- Fallback: hardcoded wheelchair measurement fields -->
<t t-if="not mfields">
<div class="row g-3">
<t t-foreach="[
('seat_width', 'Seat Width', 'seat_width_unit', 'inches'),
('seat_depth', 'Seat Depth', 'seat_depth_unit', 'inches'),
('finished_seat_to_floor_height', 'Finished Seat to Floor Height', 'seat_to_floor_unit', 'inches'),
('back_cane_height', 'Back Cane Height', 'cane_height_unit', 'inches'),
('finished_back_height', 'Finished Back Height', 'back_height_unit', 'inches'),
('finished_leg_rest_length', 'Finished Leg Rest Length', 'leg_rest_unit', 'inches'),
]" t-as="mfield">
<div class="col-md-6">
<div class="wc-measurement-field p-3 border rounded">
<label class="form-label fw-bold" t-out="mfield[1]"/>
<div class="d-flex gap-2 align-items-center">
<input type="number" step="0.25" t-att-name="mfield[0]"
class="form-control wc-measurement-input"
t-att-data-upcharge-field="mfield[0]"
t-att-value="field_values.get(mfield[0], '')"/>
<select t-att-name="mfield[2]" class="form-select wc-unit-select" style="width:100px;">
<option value="cm"
t-att-selected="field_values.get(mfield[2]) == 'cm'">cm</option>
<option value="inches"
t-att-selected="not field_values.get(mfield[2]) or field_values.get(mfield[2]) == 'inches'">inches</option>
</select>
<span class="wc-upcharge-badge d-none"/>
</div>
</div>
</div>
</t>
<!-- Client Weight -->
<div class="col-md-6">
<div class="wc-measurement-field p-3 border rounded">
<label class="form-label fw-bold">Client Weight</label>
<div class="d-flex gap-2 align-items-center">
<input type="number" step="1" name="client_weight"
class="form-control wc-measurement-input"
data-upcharge-field="client_weight"
t-att-value="assessment and assessment.client_weight or ''"/>
<select name="client_weight_unit" class="form-select wc-unit-select" style="width:100px;">
<option value="kg"
t-att-selected="assessment and assessment.client_weight_unit == 'kg'">kg</option>
<option value="lbs"
t-att-selected="not assessment or assessment.client_weight_unit == 'lbs'">lbs</option>
</select>
<span class="wc-upcharge-badge d-none"/>
</div>
</div>
</div>
</div>
</t>
<!-- Upcharges Preview -->
<div id="upchargePreview" class="mt-3 d-none">
<h5>Auto-Applied Upcharges</h5>
<div id="upchargeList"/>
</div>
</template>
<!-- ============================================================ -->
<!-- SUB-TEMPLATE: Product Selection (step_type=product_select) -->
<!-- ============================================================ -->
<template id="fq_step_product_select" name="Step: Product Selection">
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
<t t-if="fstep.help_text">
<p class="text-muted"><t t-out="fstep.help_text"/></p>
</t>
<div class="mb-3 wc-search-container" style="position:relative;z-index:100;">
<label class="form-label fw-bold">Search Product</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-search"/></span>
<input type="text" class="form-control wc-product-search"
id="frameSearch" t-att-data-section="fstep.section_code or 'frame'"
placeholder="Type product name, model, or ADP code..."
autocomplete="off"/>
</div>
<div id="frameSearchResults" class="list-group d-none"
style="position:absolute;z-index:1060;width:100%;max-height:300px;overflow-y:auto;background:var(--bs-body-bg);box-shadow:0 8px 30px rgba(0,0,0,.18);border:1px solid var(--bs-border-color);border-top:none;border-radius:0 0 .5rem .5rem;"/>
<input type="hidden" name="frame_product_tmpl_id" id="frameProductTmplId"
t-att-value="assessment.frame_product_tmpl_id.id if assessment and assessment.frame_product_tmpl_id else ''"/>
<input type="hidden" name="frame_product_id" id="frameProductId"
t-att-value="assessment.frame_product_id.id if assessment and assessment.frame_product_id else ''"/>
</div>
<div id="selectedFrame" class="d-none" style="position:relative;z-index:1;">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="card-title mb-1" id="selectedFrameName"/>
<div class="d-flex gap-4 text-muted small">
<span>ADP Code: <strong id="selectedFrameCode"/></span>
<span>ADP Price: $<strong id="selectedFramePrice"/></span>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary"
id="clearFrame"><i class="fa fa-times me-1"/>Change</button>
</div>
<!-- Frame Configurator (shown when template has multiple variants) -->
<div id="frameConfigurator" class="wc-configurator-panel d-none">
<div class="wc-config-title">
<i class="fa fa-sliders me-2"/>Configure Options
</div>
<div id="frameAttributeSelectors" class="row g-2"/>
<div id="frameVariantInfo" class="d-none">
<span class="wc-variant-resolved">
<i class="fa fa-check-circle"/>
<span>Resolved: <strong id="frameVariantName"/></span>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Standard options for this section -->
<div id="standardFrameOptions" class="mt-3" style="position:relative;z-index:1;">
<h5>Standard Options</h5>
<div id="frameOptionsGrid" class="row g-2"/>
</div>
<div class="mt-3" style="position:relative;z-index:1;">
<label class="form-label">Notes</label>
<textarea name="frame_notes" class="form-control" rows="2"
t-out="assessment.frame_notes if assessment else ''"/>
</div>
</template>
<!-- ============================================================ -->
<!-- SUB-TEMPLATE: Options &amp; Accessories (step_type=options) -->
<!-- ============================================================ -->
<template id="fq_step_options" name="Step: Options">
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
<t t-if="fstep.help_text">
<p class="text-muted"><t t-out="fstep.help_text"/></p>
</t>
<!-- Seating/Positioning sections with accordion -->
<t t-if="fstep.section_code == 'seating'">
<p class="text-muted">Select seating and positioning devices. Choose Modular or Custom Fabricated for each.</p>
<div class="accordion" id="seatingAccordion">
<t t-foreach="sections" t-as="section">
<t t-if="section.has_build_type and section.code != 'accessories'">
<div class="accordion-item wc-equipment-section"
t-att-data-equipment-type="section.equipment_type"
t-att-style="'' if (section.equipment_type in ('both', equipment_type) or (section.equipment_type == 'wheelchair' and equipment_type in ('manual_wheelchair', 'power_wheelchair'))) else 'display:none;'">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button"
data-bs-toggle="collapse"
t-attf-data-bs-target="#collapse_#{section.code}">
<i t-attf-class="fa #{section.icon or 'fa-cube'} me-2"/>
<t t-out="section.name"/>
</button>
</h2>
<div t-attf-id="collapse_#{section.code}" class="accordion-collapse collapse"
data-bs-parent="#seatingAccordion">
<div class="accordion-body">
<!-- Build Type Toggle -->
<div class="mb-3">
<label class="form-label fw-bold">Build Type</label>
<div class="d-flex gap-2">
<label class="btn btn-sm btn-outline-secondary wc-radio-btn">
<input type="radio" t-attf-name="section_build_#{section.code}"
value="modular" checked="checked"
class="wc-section-build-type"
t-att-data-section="section.code"/>
Modular
</label>
<label class="btn btn-sm btn-outline-secondary wc-radio-btn">
<input type="radio" t-attf-name="section_build_#{section.code}"
value="custom_fabricated"
class="wc-section-build-type"
t-att-data-section="section.code"/>
Custom Fabricated
</label>
</div>
</div>
<!-- Measurement fields if section has them -->
<t t-if="section.has_width or section.has_depth or section.has_height">
<div class="row g-2 mb-3">
<t t-if="section.has_width">
<div class="col-md-4">
<label class="form-label" t-out="section.width_label"/>
<input type="number" step="0.25" class="form-control"
t-attf-name="section_width_#{section.code}"/>
</div>
</t>
<t t-if="section.has_depth">
<div class="col-md-4">
<label class="form-label" t-out="section.depth_label"/>
<input type="number" step="0.25" class="form-control"
t-attf-name="section_depth_#{section.code}"/>
</div>
</t>
<t t-if="section.has_height">
<div class="col-md-4">
<label class="form-label" t-out="section.height_label"/>
<input type="number" step="0.25" class="form-control"
t-attf-name="section_height_#{section.code}"/>
</div>
</t>
</div>
</t>
<!-- Product options for this section -->
<div class="wc-section-options" t-att-data-section="section.code"
t-att-data-section-id="section.id">
<div class="wc-options-grid row g-2"/>
<!-- Custom search -->
<div class="mt-2 wc-search-container">
<input type="text" class="form-control form-control-sm wc-product-search"
t-att-data-section="section.code"
placeholder="Search for product..."
autocomplete="off"/>
<div class="wc-search-results list-group d-none"/>
</div>
</div>
<!-- Sub-sections -->
<t t-foreach="section.child_ids" t-as="child">
<div class="mt-3 ps-3 border-start">
<h6><t t-out="child.name"/></h6>
<div class="wc-section-options" t-att-data-section="child.code"
t-att-data-section-id="child.id">
<div class="wc-options-grid row g-2"/>
<div class="mt-1 wc-search-container">
<input type="text" class="form-control form-control-sm wc-product-search"
t-att-data-section="child.code"
placeholder="Search..."
autocomplete="off"/>
<div class="wc-search-results list-group d-none"/>
</div>
</div>
</div>
</t>
</div>
</div>
</div>
</t>
</t>
</div>
</t>
<!-- ADP Options &amp; Accessories (flat checkbox grids) -->
<t t-if="fstep.section_code != 'seating'">
<p class="text-muted">
Select applicable options. Items marked with * require clinical rationale.
</p>
<div id="adpOptionsGrid" class="row g-2">
<!-- Populated by JS from section options -->
</div>
<h4 class="mt-4 mb-3">Accessories</h4>
<div id="accessoriesGrid" class="row g-2">
<!-- Populated by JS -->
</div>
<div class="mt-2 wc-search-container">
<input type="text" class="form-control wc-product-search"
data-section="accessories"
placeholder="Search for accessories..."
autocomplete="off"/>
<div class="wc-search-results list-group d-none"/>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- SUB-TEMPLATE: Review &amp; Generate (step_type=review) -->
<!-- ============================================================ -->
<template id="fq_step_review" name="Step: Review &amp; Generate">
<h4 class="mb-3">Review &amp; Generate Quotation</h4>
<!-- Selected Items Summary -->
<div id="reviewSummary">
<table class="table table-sm">
<thead>
<tr>
<th>Section</th>
<th>Product</th>
<th>ADP Code</th>
<th>Build</th>
<th class="text-end">Qty</th>
<th class="text-end">Price</th>
</tr>
</thead>
<tbody id="reviewTableBody"/>
<tfoot>
<tr class="fw-bold">
<td colspan="5" class="text-end">Estimated Total:</td>
<td class="text-end" id="reviewTotal">$0.00</td>
</tr>
</tfoot>
</table>
</div>
<!-- Upcharges Summary -->
<div id="reviewUpcharges" class="d-none">
<h5>Auto-Applied Upcharges</h5>
<div id="reviewUpchargeList"/>
</div>
<!-- Measurements Summary -->
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Measurements Summary</h5>
</div>
<div class="card-body" id="reviewMeasurements"/>
</div>
<div class="mt-3">
<label class="form-label">Additional Notes</label>
<textarea name="notes" class="form-control" rows="3"
t-out="assessment.notes if assessment else ''"/>
</div>
</template>
<!-- ============================================================ -->
<!-- SUB-TEMPLATE: Custom Fields (step_type=custom) -->
<!-- ============================================================ -->
<template id="fq_step_custom" name="Step: Custom Fields">
<h4 class="mb-3"><t t-out="fstep.name"/></h4>
<t t-if="fstep.help_text">
<p class="text-muted"><t t-out="fstep.help_text"/></p>
</t>
<t t-set="cfields" t-value="step_fields.get(fstep.id, [])"/>
<t t-if="cfields">
<div class="row g-3">
<t t-foreach="cfields" t-as="cf">
<div class="col-md-6">
<div class="p-3 border rounded">
<label class="form-label fw-bold" t-out="cf.get('label', '')"/>
<t t-if="cf.get('type') == 'selection'">
<select t-att-name="cf.get('name')" class="form-select"
t-att-required="cf.get('required')">
<option value="">Select...</option>
<t t-foreach="cf.get('options', [])" t-as="opt">
<option t-att-value="opt[0]"
t-att-selected="field_values.get(cf.get('name')) == str(opt[0])"
t-out="opt[1]"/>
</t>
</select>
</t>
<t t-if="cf.get('type') == 'text'">
<textarea t-att-name="cf.get('name')" class="form-control" rows="3"
t-att-required="cf.get('required')"
t-out="field_values.get(cf.get('name'), '')"/>
</t>
<t t-if="cf.get('type') == 'boolean'">
<div class="form-check">
<input type="checkbox" class="form-check-input"
t-att-name="cf.get('name')"
t-att-checked="field_values.get(cf.get('name'))"/>
<label class="form-check-label" t-out="cf.get('label', '')"/>
</div>
</t>
<t t-if="cf.get('type') in ('float', 'integer')">
<div class="input-group">
<input t-att-type="'number'"
t-att-step="'0.01' if cf.get('type') == 'float' else '1'"
t-att-name="cf.get('name')"
class="form-control"
t-att-value="field_values.get(cf.get('name'), '')"
t-att-required="cf.get('required')"/>
<t t-if="cf.get('unit')">
<span class="input-group-text" t-out="cf.get('unit')"/>
</t>
</div>
</t>
<t t-if="cf.get('type') not in ('selection', 'text', 'boolean', 'float', 'integer')">
<input type="text" t-att-name="cf.get('name')" class="form-control"
t-att-value="field_values.get(cf.get('name'), '')"
t-att-required="cf.get('required')"/>
</t>
</div>
</div>
</t>
</div>
</t>
<t t-if="not cfields">
<div class="alert alert-info">
No custom fields configured for this step.
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- EMBEDDABLE FORM (no portal chrome — for iframe embedding) -->
<!-- ============================================================ -->
<template id="portal_quotation_form_embed" name="Equipment Assessment Form (Embed)">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div class="container-fluid py-3 wc-assessment-form" id="wcAssessmentForm">
<!-- Success/Error Messages -->
<t t-if="request.params.get('success') == 'saved'">
<div class="alert alert-success alert-dismissible fade show">
Assessment saved successfully.
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<t t-if="request.params.get('success') == 'quotation_generated'">
<div class="alert alert-success alert-dismissible fade show">
<i class="fa fa-check-circle me-1"/> Quotation generated!
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<t t-if="request.params.get('error')">
<div class="alert alert-danger alert-dismissible fade show">
<t t-out="request.params.get('error')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Dynamic Step Indicator -->
<div class="wc-steps mb-4">
<div class="d-flex justify-content-between">
<t t-foreach="flow_steps" t-as="fstep">
<div t-attf-class="wc-step-indicator text-center flex-fill #{'active' if fstep_index == 0 else ''}"
t-att-data-step="fstep_index + 1">
<div class="wc-step-number rounded-circle d-inline-flex align-items-center justify-content-center">
<t t-out="fstep_index + 1"/>
</div>
<div class="wc-step-label small mt-1">
<t t-out="fstep.name"/>
</div>
</div>
</t>
</div>
</div>
<form method="post"
t-att-action="'/quotation/form/' + access_token + '/save'"
id="wcForm" class="wc-form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="assessment_id"
t-att-value="assessment.id if assessment else 0"/>
<input type="hidden" name="current_step" id="currentStep" value="1"/>
<input type="hidden" name="action" id="formAction" value="save"/>
<input type="hidden" name="access_token" id="accessToken"
t-att-value="access_token"/>
<!-- Hidden fields for line data -->
<input type="hidden" name="line_product_ids" id="lineProductIds" value=""/>
<input type="hidden" name="line_section_ids" id="lineSectionIds" value=""/>
<input type="hidden" name="line_build_types" id="lineBuildTypes" value=""/>
<input type="hidden" name="line_quantities" id="lineQuantities" value=""/>
<input type="hidden" name="line_rationales" id="lineRationales" value=""/>
<p class="text-muted text-center mb-3">
<i class="fa fa-cogs me-1"/>
Equipment Assessment Form
</p>
<!-- NOTE: The embed template reuses the same step structure.
For a full implementation, you would include the same step
content from the main template. For now we render a minimal
placeholder that the JS widget will populate. -->
<div class="text-center py-5 text-muted">
<i class="fa fa-spinner fa-spin fa-2x mb-3 d-block"/>
Loading assessment form...
</div>
<!-- Navigation Buttons -->
<div class="wc-form-navigation mt-4 border-top pt-3">
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-outline-secondary" id="btnSaveDraft">
<i class="fa fa-save me-1"/> Save Draft
</button>
<div>
<button type="button" class="btn btn-secondary d-none" id="btnPrev">
<i class="fa fa-arrow-left me-1"/> Previous
</button>
<button type="button" class="btn btn-primary" id="btnNext">
Next <i class="fa fa-arrow-right ms-1"/>
</button>
<button type="submit" class="btn btn-success d-none" id="btnGenerate"
name="action" value="generate">
<i class="fa fa-magic me-1"/> Generate Quotation
</button>
</div>
</div>
</div>
</form>
</div>
</t>
</template>
<!-- ============================================================ -->
<!-- PUBLIC FORM — INVALID TOKEN PAGE -->
<!-- ============================================================ -->
<template id="public_form_invalid" name="Invalid Assessment Link">
<t t-call="web.frontend_layout">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<i class="fa fa-chain-broken fa-4x text-muted mb-3 d-block"/>
<h3 class="mb-3">This link is no longer valid</h3>
<t t-if="status == 'not_found'">
<p class="text-muted">
The assessment form you're trying to access doesn't exist
or the link has expired.
</p>
</t>
<t t-if="status == 'cancelled'">
<p class="text-muted">
This assessment has been cancelled and is no longer
available for editing.
</p>
</t>
<a href="/" class="btn btn-primary mt-3">
<i class="fa fa-home me-1"/> Go Home
</a>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,496 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- ASSESSMENT TREE VIEW -->
<!-- ============================================================ -->
<record id="view_wc_assessment_tree" model="ir.ui.view">
<field name="name">fusion.wc.assessment.tree</field>
<field name="model">fusion.wc.assessment</field>
<field name="arch" type="xml">
<list decoration-info="state == 'draft'"
decoration-warning="state == 'review'"
decoration-success="state == 'quoted'"
decoration-muted="state == 'cancelled'">
<field name="reference"/>
<field name="client_name"/>
<field name="equipment_type"/>
<field name="wheelchair_type"/>
<field name="client_type"/>
<field name="build_type"/>
<field name="sales_rep_id"/>
<field name="assessment_date"/>
<field name="total_estimate" sum="Total"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-warning="state == 'review'"
decoration-success="state == 'quoted'"
decoration-danger="state == 'cancelled'"/>
</list>
</field>
</record>
<!-- ============================================================ -->
<!-- ASSESSMENT KANBAN VIEW -->
<!-- ============================================================ -->
<record id="view_wc_assessment_kanban" model="ir.ui.view">
<field name="name">fusion.wc.assessment.kanban</field>
<field name="model">fusion.wc.assessment</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_kanban_small_column">
<field name="state"/>
<field name="reference"/>
<field name="client_name"/>
<field name="equipment_type"/>
<field name="sales_rep_id"/>
<field name="total_estimate"/>
<templates>
<t t-name="card">
<div class="oe_kanban_content">
<strong><field name="reference"/></strong>
<div><field name="client_name"/></div>
<div class="text-muted">
<field name="equipment_type"/>
</div>
<div>
<field name="sales_rep_id" widget="many2one_avatar_user"/>
<span class="float-end fw-bold">
$<field name="total_estimate"/>
</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ============================================================ -->
<!-- ASSESSMENT SEARCH VIEW -->
<!-- ============================================================ -->
<record id="view_wc_assessment_search" model="ir.ui.view">
<field name="name">fusion.wc.assessment.search</field>
<field name="model">fusion.wc.assessment</field>
<field name="arch" type="xml">
<search string="Wheelchair Assessments">
<field name="reference"/>
<field name="client_name"/>
<field name="partner_id"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<separator/>
<filter name="draft" string="In Progress"
domain="[('state', '=', 'draft')]"/>
<filter name="review" string="Ready for Review"
domain="[('state', '=', 'review')]"/>
<filter name="quoted" string="Quoted"
domain="[('state', '=', 'quoted')]"/>
<separator/>
<filter name="manual" string="Manual Wheelchair"
domain="[('equipment_type', '=', 'manual_wheelchair')]"/>
<filter name="power" string="Power Wheelchair / Scooter"
domain="[('equipment_type', '=', 'power_wheelchair')]"/>
<filter name="walker" string="Walker / Ambulation Aid"
domain="[('equipment_type', '=', 'walker')]"/>
<separator/>
<filter name="my_assessments" string="My Assessments"
domain="[('sales_rep_id', '=', uid)]"/>
<separator/>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="group_equipment" string="Equipment Type"
context="{'group_by': 'equipment_type'}"/>
<filter name="group_rep" string="Sales Rep"
context="{'group_by': 'sales_rep_id'}"/>
<filter name="group_date" string="Date"
context="{'group_by': 'assessment_date:month'}"/>
</search>
</field>
</record>
<!-- ============================================================ -->
<!-- ASSESSMENT FORM VIEW -->
<!-- ============================================================ -->
<record id="view_wc_assessment_form" model="ir.ui.view">
<field name="name">fusion.wc.assessment.form</field>
<field name="model">fusion.wc.assessment</field>
<field name="arch" type="xml">
<form string="Wheelchair Assessment">
<header>
<button name="action_generate_quotation" string="Generate Quotation"
type="object" class="btn-primary"
invisible="state not in ('draft', 'review')"
confirm="Generate a quotation from this assessment?"/>
<button name="action_mark_review" string="Mark Ready"
type="object" class="btn-secondary"
invisible="state != 'draft'"/>
<button name="action_cancel" string="Cancel"
type="object" class="btn-secondary"
invisible="state in ('quoted', 'cancelled')"/>
<button name="action_reset_draft" string="Reset to Draft"
type="object" class="btn-secondary"
invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,review,quoted"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_quotation" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<span class="o_stat_text">Quotation</span>
</button>
<button name="action_open_portal_form" type="object"
class="oe_stat_button" icon="fa-external-link">
<span class="o_stat_text">Open Form</span>
</button>
<button name="action_generate_share_link" type="object"
class="oe_stat_button" icon="fa-share-alt">
<span class="o_stat_text">Share Link</span>
</button>
</div>
<div class="oe_title">
<h1>
<field name="reference" readonly="1"/>
</h1>
</div>
<notebook>
<!-- TAB 1: CLIENT & EQUIPMENT -->
<page string="Client &amp; Equipment" name="client">
<group>
<group string="Client">
<field name="partner_id"
invisible="create_new_partner"/>
<field name="create_new_partner"/>
<field name="client_first_name"
invisible="not create_new_partner"/>
<field name="client_last_name"
invisible="not create_new_partner"/>
<field name="client_phone"
invisible="not create_new_partner"/>
<field name="client_email"
invisible="not create_new_partner"/>
<field name="client_health_card"/>
<field name="client_dob"/>
</group>
<group string="Equipment">
<field name="equipment_type"/>
<field name="wheelchair_type"
invisible="equipment_type != 'manual_wheelchair'"/>
<field name="powerchair_type"
invisible="equipment_type != 'power_wheelchair'"/>
<field name="walker_type"
invisible="equipment_type != 'walker'"/>
<field name="client_type"/>
<field name="build_type"
invisible="equipment_type == 'walker'"/>
<field name="reason_for_application"/>
</group>
</group>
<group>
<group string="Assessment">
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="assessment_date"/>
</group>
</group>
</page>
<!-- TAB 2: ADP MEASUREMENTS -->
<page string="Measurements" name="measurements">
<!-- MANUAL WHEELCHAIR MEASUREMENTS (Section 2b) -->
<group string="Manual Wheelchair Prescription Details"
invisible="equipment_type != 'manual_wheelchair'">
<group>
<label for="seat_width"/>
<div class="d-flex gap-2">
<field name="seat_width" class="oe_inline"/>
<field name="seat_width_unit" class="oe_inline"/>
</div>
<label for="seat_depth"/>
<div class="d-flex gap-2">
<field name="seat_depth" class="oe_inline"/>
<field name="seat_depth_unit" class="oe_inline"/>
</div>
<label for="finished_seat_to_floor_height"/>
<div class="d-flex gap-2">
<field name="finished_seat_to_floor_height" class="oe_inline"/>
<field name="seat_to_floor_unit" class="oe_inline"/>
</div>
<label for="back_cane_height"/>
<div class="d-flex gap-2">
<field name="back_cane_height" class="oe_inline"/>
<field name="cane_height_unit" class="oe_inline"/>
</div>
</group>
<group>
<label for="finished_back_height"/>
<div class="d-flex gap-2">
<field name="finished_back_height" class="oe_inline"/>
<field name="back_height_unit" class="oe_inline"/>
</div>
<label for="finished_leg_rest_length"/>
<div class="d-flex gap-2">
<field name="finished_leg_rest_length" class="oe_inline"/>
<field name="leg_rest_unit" class="oe_inline"/>
</div>
<label for="client_weight"/>
<div class="d-flex gap-2">
<field name="client_weight" class="oe_inline"/>
<field name="client_weight_unit" class="oe_inline"/>
</div>
</group>
</group>
<!-- WALKER / ROLLATOR MEASUREMENTS (Section 2a) -->
<group string="Wheeled Walker Prescription Details"
invisible="equipment_type != 'walker'">
<group>
<label for="walker_seat_height"/>
<div class="d-flex gap-2">
<field name="walker_seat_height" class="oe_inline"/>
<field name="walker_seat_height_unit" class="oe_inline"/>
</div>
<label for="push_handle_height"/>
<div class="d-flex gap-2">
<field name="push_handle_height" class="oe_inline"/>
<field name="push_handle_height_unit" class="oe_inline"/>
</div>
<field name="hand_grips"/>
<field name="forearm_attachments"/>
<label for="width_between_push_handles"/>
<div class="d-flex gap-2">
<field name="width_between_push_handles" class="oe_inline"/>
<field name="push_handle_width_unit" class="oe_inline"/>
</div>
</group>
<group>
<label for="client_weight"/>
<div class="d-flex gap-2">
<field name="client_weight" class="oe_inline"/>
<field name="client_weight_unit" class="oe_inline"/>
</div>
<field name="walker_brakes"/>
<field name="walker_brake_type"/>
<field name="walker_num_wheels"/>
<field name="walker_wheel_size"/>
<field name="walker_back_support"/>
</group>
</group>
<group string="Walker ADP Funded Options"
invisible="equipment_type != 'walker'">
<group>
<field name="walker_adolescent_wheeled_walker"/>
<field name="walker_adolescent_walking_frame"/>
<field name="walker_adolescent_standing_frame"/>
</group>
</group>
<!-- POWER BASE / SCOOTER MEASUREMENTS (Section 2c) -->
<group string="Power Device Prescription Details"
invisible="equipment_type != 'power_wheelchair'">
<group>
<label for="seat_width"/>
<div class="d-flex gap-2">
<field name="seat_width" class="oe_inline"/>
<field name="seat_width_unit" class="oe_inline"/>
</div>
<label for="finished_back_height"/>
<div class="d-flex gap-2">
<field name="finished_back_height" class="oe_inline"/>
<field name="back_height_unit" class="oe_inline"/>
</div>
<label for="finished_seat_to_floor_height"/>
<div class="d-flex gap-2">
<field name="finished_seat_to_floor_height" class="oe_inline"/>
<field name="seat_to_floor_unit" class="oe_inline"/>
</div>
</group>
<group>
<label for="finished_leg_rest_length"/>
<div class="d-flex gap-2">
<field name="finished_leg_rest_length" class="oe_inline"/>
<field name="leg_rest_unit" class="oe_inline"/>
</div>
<label for="seat_depth"/>
<div class="d-flex gap-2">
<field name="seat_depth" class="oe_inline"/>
<field name="seat_depth_unit" class="oe_inline"/>
</div>
<label for="client_weight"/>
<div class="d-flex gap-2">
<field name="client_weight" class="oe_inline"/>
<field name="client_weight_unit" class="oe_inline"/>
</div>
</group>
</group>
<group string="Power Base ADP Funded Options"
invisible="equipment_type != 'power_wheelchair'">
<group>
<field name="pw_adjustable_tension_back"/>
<field name="pw_midline_control"/>
<field name="pw_manual_recline"/>
<field name="pw_angle_adjustable_footplates"/>
<field name="pw_manual_elevating_legrests"/>
<field name="pw_swingaway_bracket"/>
</group>
<group>
<field name="pw_front_riggings"/>
<field name="pw_seat_package_1"/>
<field name="pw_seat_package_2"/>
<field name="pw_oxygen_tank"/>
<field name="pw_ventilator_tray"/>
</group>
</group>
<group string="Specialty Components (* Provide Clinical Rationale)"
invisible="equipment_type != 'power_wheelchair'">
<group>
<field name="pw_specialty_1_joystick"/>
<field name="pw_specialty_2_chin"/>
<field name="pw_specialty_3_touch"/>
<field name="pw_specialty_4_proximity"/>
</group>
<group>
<field name="pw_specialty_5_breath"/>
<field name="pw_specialty_6_scanners"/>
<field name="pw_auto_correction"/>
</group>
<group colspan="2">
<field name="pw_specialty_rationale"
placeholder="Clinical rationale for specialty components..."
invisible="not pw_specialty_1_joystick and not pw_specialty_2_chin and not pw_specialty_3_touch and not pw_specialty_4_proximity and not pw_specialty_5_breath and not pw_specialty_6_scanners and not pw_auto_correction"/>
</group>
</group>
<group string="Power Positioning Devices"
invisible="equipment_type != 'power_wheelchair'">
<group>
<field name="pw_power_tilt_only"/>
<field name="pw_power_recline_only"/>
<field name="pw_power_tilt_recline"/>
</group>
<group>
<field name="pw_power_elevating_footrests"/>
<field name="pw_multi_function_control"/>
<field name="pw_power_add_on"/>
</group>
</group>
</page>
<!-- TAB 3: FRAME -->
<page string="Frame" name="frame">
<group>
<group>
<field name="frame_product_id"/>
</group>
</group>
<field name="frame_notes" placeholder="Frame notes..."/>
</page>
<!-- TAB 4: SELECTED ITEMS -->
<page string="Selected Items" name="lines">
<field name="line_ids">
<list editable="bottom">
<field name="section_id"/>
<field name="product_id"/>
<field name="product_name"/>
<field name="build_type"/>
<field name="quantity"/>
<field name="adp_device_code"/>
<field name="adp_price"/>
<field name="unit_price"/>
<field name="subtotal" sum="Total"/>
<field name="adp_portion" sum="ADP Total"/>
<field name="client_portion" sum="Client Total"/>
<field name="is_upcharge"/>
<field name="upcharge_reason"
invisible="not is_upcharge"/>
</list>
</field>
</page>
<!-- TAB 5: UPCHARGES -->
<page string="Upcharges" name="upcharges">
<field name="upcharge_line_ids" readonly="1">
<list>
<field name="adp_device_code"/>
<field name="product_name"/>
<field name="upcharge_reason"/>
<field name="unit_price"/>
<field name="adp_portion"/>
</list>
</field>
</page>
<!-- TAB 6: QUOTATION -->
<page string="Quotation" name="quotation">
<group>
<group>
<field name="sale_order_id"/>
<field name="total_estimate"/>
<field name="total_adp_estimate"/>
<field name="total_client_estimate"/>
</group>
</group>
<field name="notes" placeholder="Additional notes..."/>
</page>
<!-- TAB 7: SHARING -->
<page string="Sharing" name="sharing">
<div class="alert alert-info" role="alert"
invisible="access_token">
<i class="fa fa-info-circle me-1"/>
Click <b>Share Link</b> above to generate a public URL
that can be opened without logging in, or embedded in
an external website via an iframe.
</div>
<group invisible="not access_token">
<group string="Public Access">
<field name="public_url" widget="CopyClipboardChar"
readonly="1" string="Public URL"/>
<field name="portal_url" widget="CopyClipboardChar"
readonly="1" string="Portal URL (login required)"/>
<field name="access_token" readonly="1"/>
</group>
<group string="Embed Code">
<div colspan="2">
<p class="text-muted mb-2">
Copy this snippet to embed the form in an external page:
</p>
<code style="display:block;white-space:pre-wrap;background:#f8f9fa;padding:12px;border-radius:6px;font-size:13px;">&lt;iframe src="<field name="public_url" readonly="1"/>/embed"
width="100%" height="900"
frameborder="0"
allow="clipboard-write"&gt;&lt;/iframe&gt;</code>
</div>
</group>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ============================================================ -->
<!-- ASSESSMENT ACTION -->
<!-- ============================================================ -->
<record id="action_wc_assessment" model="ir.actions.act_window">
<field name="name">Wheelchair Assessments</field>
<field name="res_model">fusion.wc.assessment</field>
<field name="view_mode">list,kanban,form</field>
<field name="context">{'search_default_my_assessments': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a wheelchair assessment
</p>
<p>
Start a new wheelchair assessment to configure a chair
and automatically generate a quotation.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================
Configuration Flow — Tree View
============================================================ -->
<record id="view_wc_config_flow_tree" model="ir.ui.view">
<field name="name">fusion.wc.config.flow.tree</field>
<field name="model">fusion.wc.config.flow</field>
<field name="arch" type="xml">
<list string="Configuration Flows">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="equipment_type"/>
<field name="state" widget="badge"
decoration-success="state == 'active'"
decoration-info="state == 'draft'"
decoration-muted="state == 'archived'"/>
<field name="step_count"/>
<field name="node_count"/>
<field name="connection_count"/>
</list>
</field>
</record>
<!-- ============================================================
Configuration Flow — Form View
============================================================ -->
<record id="view_wc_config_flow_form" model="ir.ui.view">
<field name="name">fusion.wc.config.flow.form</field>
<field name="model">fusion.wc.config.flow</field>
<field name="arch" type="xml">
<form string="Configuration Flow">
<header>
<button name="action_open_designer" type="object"
string="Open Visual Designer"
class="btn-primary"
icon="fa-sitemap"/>
<button name="action_new_assessment_form" type="object"
string="New Assessment"
class="btn-secondary"
icon="fa-external-link"/>
<button name="action_activate" type="object"
string="Activate"
class="btn-success"
invisible="state == 'active'"/>
<button name="action_archive" type="object"
string="Archive"
invisible="state == 'archived'"/>
<button name="action_reset_draft" type="object"
string="Reset to Draft"
invisible="state == 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,active,archived"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. Standard Manual Wheelchair Config"/>
</h1>
</div>
<group>
<group>
<field name="equipment_type"/>
<field name="sequence"/>
<field name="active" invisible="1"/>
</group>
<group>
<field name="step_count"/>
<field name="node_count"/>
<field name="connection_count"/>
</group>
</group>
<notebook>
<page string="Form Steps" name="form_steps">
<field name="step_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="step_type"/>
<field name="icon"/>
<field name="section_code"/>
<field name="is_required"/>
</list>
</field>
<div class="mt-2">
<button name="action_create_default_steps" type="object"
string="Generate Default Steps"
class="btn-secondary"
icon="fa-magic"
confirm="This will replace existing steps. Continue?"/>
</div>
</page>
<page string="Description" name="description">
<field name="description" placeholder="Describe this configuration flow..."/>
</page>
<page string="Nodes" name="nodes">
<field name="node_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="node_type"/>
<field name="section_id"/>
<field name="decision_field"
invisible="node_type != 'decision'"/>
<field name="decision_operator"
invisible="node_type != 'decision'"/>
<field name="decision_value"
invisible="node_type != 'decision'"/>
<field name="action_type"
invisible="node_type != 'action'"/>
<field name="measurement_field"
invisible="node_type != 'measurement_check'"/>
<field name="comparison"
invisible="node_type != 'measurement_check'"/>
<field name="threshold_value"
invisible="node_type != 'measurement_check'"/>
</list>
</field>
</page>
<page string="Connections" name="connections">
<field name="connection_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="source_node_id"
domain="[('flow_id', '=', parent.id)]"/>
<field name="target_node_id"
domain="[('flow_id', '=', parent.id)]"/>
<field name="source_port"/>
<field name="label"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ============================================================
Configuration Flow — Action
============================================================ -->
<record id="action_wc_config_flow" model="ir.actions.act_window">
<field name="name">Configuration Flows</field>
<field name="res_model">fusion.wc.config.flow</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a Configuration Flow
</p>
<p>
Configuration flows define visual decision trees that control
which options are shown, hidden, or required during a wheelchair
assessment. Use the Visual Designer to build flows graphically.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- SECTION TREE VIEW -->
<!-- ============================================================ -->
<record id="view_wc_section_tree" model="ir.ui.view">
<field name="name">fusion.wc.section.tree</field>
<field name="model">fusion.wc.section</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="parent_id"/>
<field name="equipment_type"/>
<field name="has_build_type"/>
<field name="is_adp_options_section"/>
<field name="option_count" string="Options"/>
<field name="active" column_invisible="1"/>
</list>
</field>
</record>
<!-- ============================================================ -->
<!-- SECTION OPTION ACTION (for stat button — must be before form) -->
<!-- ============================================================ -->
<record id="action_wc_section_option" model="ir.actions.act_window">
<field name="name">Section Options</field>
<field name="res_model">fusion.wc.section.option</field>
<field name="view_mode">list,form</field>
<field name="context">{'default_section_id': active_id}</field>
<field name="domain">[('section_id', '=', active_id)]</field>
</record>
<!-- ============================================================ -->
<!-- SECTION FORM VIEW -->
<!-- ============================================================ -->
<record id="view_wc_section_form" model="ir.ui.view">
<field name="name">fusion.wc.section.form</field>
<field name="model">fusion.wc.section</field>
<field name="arch" type="xml">
<form string="Wheelchair Section">
<header>
<button name="action_auto_populate_options"
string="Auto-Populate Products from Inventory"
type="object" class="btn-secondary"
icon="fa-magic"
confirm="This will scan your product inventory for ADP device codes matching this section and add them as options. Continue?"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="%(fusion_quotations.action_wc_section_option)d"
type="action" class="oe_stat_button" icon="fa-list"
context="{'default_section_id': id, 'search_default_section_id': id}">
<field name="option_count" widget="statinfo" string="Options"/>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Section Name"/>
</h1>
</div>
<group>
<group>
<field name="code"/>
<field name="sequence"/>
<field name="parent_id"/>
<field name="equipment_type"/>
<field name="icon" placeholder="fa-wheelchair"/>
<field name="product_category_id"/>
</group>
<group>
<field name="required"/>
<field name="allow_multiple"/>
<field name="is_adp_options_section"/>
<field name="has_build_type"/>
<field name="active"/>
</group>
</group>
<group string="Measurement Fields" col="4">
<field name="has_width"/>
<field name="width_label" invisible="not has_width"/>
<field name="has_depth"/>
<field name="depth_label" invisible="not has_depth"/>
<field name="has_height"/>
<field name="height_label" invisible="not has_height"/>
<field name="has_length"/>
<field name="length_label" invisible="not has_length"/>
</group>
<field name="description" placeholder="Help text for sales reps..."/>
<notebook>
<page string="Product Options" name="options">
<field name="option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="product_tmpl_id"/>
<field name="variant_count" string="Variants"/>
<field name="is_standard"/>
<field name="adp_device_code"/>
<field name="adp_price"/>
<field name="list_price"/>
<field name="available_build_types"/>
<field name="requires_clinical_rationale"/>
</list>
</field>
</page>
<page string="Sub-Sections" name="children">
<field name="child_ids">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="has_build_type"/>
<field name="option_count" string="Options"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ============================================================ -->
<!-- SECTION ACTION -->
<!-- ============================================================ -->
<record id="action_wc_section" model="ir.actions.act_window">
<field name="name">Wheelchair Sections</field>
<field name="res_model">fusion.wc.section</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configure wheelchair sections
</p>
<p>
Define sections like Frame, Cushion, Backrest, etc.
and add product options to each section.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- UPCHARGE RULE TREE VIEW -->
<!-- ============================================================ -->
<record id="view_wc_upcharge_rule_tree" model="ir.ui.view">
<field name="name">fusion.wc.upcharge.rule.tree</field>
<field name="model">fusion.wc.upcharge.rule</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="trigger_type"/>
<field name="adp_device_code"/>
<field name="equipment_type"/>
<field name="mutually_exclusive_group"/>
<field name="active"/>
</list>
</field>
</record>
<!-- ============================================================ -->
<!-- UPCHARGE RULE FORM VIEW -->
<!-- ============================================================ -->
<record id="view_wc_upcharge_rule_form" model="ir.ui.view">
<field name="name">fusion.wc.upcharge.rule.form</field>
<field name="model">fusion.wc.upcharge.rule</field>
<field name="arch" type="xml">
<form string="Upcharge Rule">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Rule Name"/>
</h1>
</div>
<group>
<group string="Trigger">
<field name="trigger_type"/>
<field name="equipment_type"/>
<field name="sequence"/>
<field name="active"/>
</group>
<group string="Result">
<field name="adp_device_code"/>
<field name="adp_device_code_id" readonly="1"/>
<field name="product_id"/>
<field name="mutually_exclusive_group"/>
</group>
</group>
<!-- Measurement trigger fields -->
<group string="Measurement Condition"
invisible="trigger_type != 'measurement'">
<group>
<field name="measurement_field"/>
<field name="comparison"/>
<field name="threshold_value"/>
</group>
</group>
<!-- Weight trigger fields -->
<group string="Weight Condition"
invisible="trigger_type != 'weight'">
<group>
<field name="weight_min"/>
<field name="weight_max"/>
</group>
</group>
<!-- Dimension mismatch fields -->
<group string="Dimension Mismatch Condition"
invisible="trigger_type != 'dimension_mismatch'">
<group>
<field name="compare_field_1"/>
<field name="compare_field_2"/>
</group>
</group>
<group>
<field name="description" placeholder="Explanation shown to sales rep when rule triggers..."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============================================================ -->
<!-- UPCHARGE RULE ACTION -->
<!-- ============================================================ -->
<record id="action_wc_upcharge_rule" model="ir.actions.act_window">
<field name="name">Upcharge Rules</field>
<field name="res_model">fusion.wc.upcharge.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configure upcharge rules
</p>
<p>
Define rules that auto-add ADP upcharge codes based on
wheelchair measurements (e.g. WAMA for width > 18").
</p>
</field>
</record>
</odoo>