changes
This commit is contained in:
4
Work in Progress/fusion_quotations/__init__.py
Normal file
4
Work in Progress/fusion_quotations/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
61
Work in Progress/fusion_quotations/__manifest__.py
Normal file
61
Work in Progress/fusion_quotations/__manifest__.py
Normal 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,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import portal_quotation
|
||||
from . import quotation_api
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
388
Work in Progress/fusion_quotations/controllers/quotation_api.py
Normal file
388
Work in Progress/fusion_quotations/controllers/quotation_api.py
Normal 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}
|
||||
@@ -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>
|
||||
414
Work in Progress/fusion_quotations/data/section_seed_data.xml
Normal file
414
Work in Progress/fusion_quotations/data/section_seed_data.xml
Normal 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>
|
||||
@@ -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>
|
||||
14
Work in Progress/fusion_quotations/models/__init__.py
Normal file
14
Work in Progress/fusion_quotations/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import equipment_type
|
||||
from . import wc_section
|
||||
from . import wc_section_option
|
||||
from . import wc_upcharge_rule
|
||||
from . import wc_assessment
|
||||
from . import wc_assessment_line
|
||||
from . import sale_order
|
||||
from . import wc_config_flow
|
||||
from . import wc_config_flow_node
|
||||
from . import wc_config_flow_connection
|
||||
from . import wc_config_flow_node_option
|
||||
from . import wc_config_flow_step
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
Work in Progress/fusion_quotations/models/equipment_type.py
Normal file
23
Work in Progress/fusion_quotations/models/equipment_type.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class EquipmentType(models.Model):
|
||||
_name = 'fusion.equipment.type'
|
||||
_description = 'Equipment Type'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True,
|
||||
help='e.g. "Manual Wheelchair", "Stair Lift", "Porch Lift"')
|
||||
code = fields.Char(string='Code', required=True, index=True,
|
||||
help='Technical code used in Selection fields, e.g. "manual_wheelchair", "stair_lift"')
|
||||
icon = fields.Char(string='Icon', default='fa-cog',
|
||||
help='FontAwesome icon class, e.g. "fa-wheelchair", "fa-arrow-up"')
|
||||
description = fields.Text(string='Description')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('code_unique', 'unique(code)', 'Equipment type code must be unique.'),
|
||||
]
|
||||
11
Work in Progress/fusion_quotations/models/sale_order.py
Normal file
11
Work in Progress/fusion_quotations/models/sale_order.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
wc_assessment_id = fields.Many2one('fusion.wc.assessment',
|
||||
string='Wheelchair Assessment', copy=False,
|
||||
help='The wheelchair assessment that generated this quotation')
|
||||
849
Work in Progress/fusion_quotations/models/wc_assessment.py
Normal file
849
Work in Progress/fusion_quotations/models/wc_assessment.py
Normal file
@@ -0,0 +1,849 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from markupsafe import Markup
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WheelchairAssessment(models.Model):
|
||||
_name = 'fusion.wc.assessment'
|
||||
_description = 'Wheelchair Assessment & Quotation Builder'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# =========================================================================
|
||||
# STATE & IDENTITY
|
||||
# =========================================================================
|
||||
state = fields.Selection([
|
||||
('draft', 'In Progress'),
|
||||
('review', 'Ready for Review'),
|
||||
('quoted', 'Quotation Generated'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='draft', tracking=True, index=True)
|
||||
|
||||
display_name = fields.Char(
|
||||
string='Display Name',
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
reference = fields.Char(
|
||||
string='Reference',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 1: CLIENT & EQUIPMENT
|
||||
# =========================================================================
|
||||
partner_id = fields.Many2one('res.partner', string='Client',
|
||||
tracking=True, index=True)
|
||||
create_new_partner = fields.Boolean(string='New Client', default=False)
|
||||
|
||||
# Client info (for new clients or override)
|
||||
client_first_name = fields.Char(string='First Name')
|
||||
client_last_name = fields.Char(string='Last Name')
|
||||
client_name = fields.Char(string='Client Name',
|
||||
compute='_compute_client_name', store=True)
|
||||
client_phone = fields.Char(string='Phone')
|
||||
client_email = fields.Char(string='Email')
|
||||
client_street = fields.Char(string='Street')
|
||||
client_street2 = fields.Char(string='Unit/Apt')
|
||||
client_city = fields.Char(string='City')
|
||||
client_state_id = fields.Many2one('res.country.state', string='Province')
|
||||
client_zip = fields.Char(string='Postal Code')
|
||||
client_country_id = fields.Many2one('res.country', string='Country')
|
||||
client_dob = fields.Date(string='Date of Birth')
|
||||
client_health_card = fields.Char(string='Health Card Number')
|
||||
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', required=True, tracking=True, index=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
return [(t.code, t.name) for t in types]
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair / Scooter'),
|
||||
('walker', 'Walker / Rollator / Ambulation Aid'),
|
||||
]
|
||||
|
||||
wheelchair_type = fields.Selection([
|
||||
('type_1', 'Type 1 - Standard'),
|
||||
('type_2', 'Type 2 - Lightweight'),
|
||||
('type_3', 'Type 3 - Ultra Lightweight'),
|
||||
('type_4', 'Type 4 - Rigid Frame'),
|
||||
('type_5', 'Type 5 - Dynamic Tilt'),
|
||||
], string='Wheelchair Category')
|
||||
|
||||
powerchair_type = fields.Selection([
|
||||
('type_1', 'Power Base Type 1'),
|
||||
('type_2', 'Power Base Type 2'),
|
||||
('type_3', 'Power Base Type 3'),
|
||||
('scooter', 'Power Scooter'),
|
||||
], string='Power Device Category')
|
||||
|
||||
walker_type = fields.Selection([
|
||||
('adult_walker_1', 'Adult Wheeled Walker Type 1'),
|
||||
('adult_walker_2', 'Adult Wheeled Walker Type 2'),
|
||||
('adult_walker_3', 'Adult Wheeled Walker Type 3'),
|
||||
('paed_walker_1', 'Paediatric Specific Wheeled Walker Type 1'),
|
||||
('paed_walker_2', 'Paediatric Specific Wheeled Walker Type 2'),
|
||||
('paed_walking_frame', 'Paediatric Specific Walking Frame'),
|
||||
('paed_standing_1', 'Paediatric Standing Frame Type 1'),
|
||||
('paed_standing_2', 'Paediatric Standing Frame Type 2'),
|
||||
('forearm_crutches', 'Forearm Crutches'),
|
||||
('stroller', 'Paediatric Specific Specialty Stroller'),
|
||||
], string='Walker / Aid Category')
|
||||
|
||||
client_type = fields.Selection([
|
||||
('reg', 'REG - Regular ADP (75/25)'),
|
||||
('ods', 'ODS - ODSP (100% ADP)'),
|
||||
('acs', 'ACS - ACSD (100% ADP)'),
|
||||
('owp', 'OWP - Ontario Works (100% ADP)'),
|
||||
('ltc', 'LTC - Long Term Care'),
|
||||
('sen', 'SEN - Senior'),
|
||||
('cca', 'CCA - Community Care'),
|
||||
], string='Client Type', default='reg')
|
||||
|
||||
build_type = fields.Selection([
|
||||
('modular', 'Modular'),
|
||||
('custom_fabricated', 'Custom Fabricated'),
|
||||
], string='Build Type', default='modular')
|
||||
|
||||
reason_for_application = fields.Selection([
|
||||
('first_access', 'First Access'),
|
||||
('additions', 'Additions'),
|
||||
('modifications', 'Modifications'),
|
||||
('replacements', 'Replacements'),
|
||||
], string='Reason for Application')
|
||||
|
||||
# Assessment context
|
||||
sales_rep_id = fields.Many2one('res.users', string='Sales Rep',
|
||||
default=lambda self: self.env.user, tracking=True, index=True)
|
||||
authorizer_id = fields.Many2one('res.partner', string='Authorizer/OT',
|
||||
tracking=True)
|
||||
assessment_date = fields.Datetime(string='Assessment Date',
|
||||
default=fields.Datetime.now)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 2: ADP PRESCRIPTION MEASUREMENTS
|
||||
# =========================================================================
|
||||
seat_width = fields.Float(string='Seat Width', digits=(10, 2))
|
||||
seat_width_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Seat Width Unit', default='inches')
|
||||
|
||||
seat_depth = fields.Float(string='Seat Depth', digits=(10, 2))
|
||||
seat_depth_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Seat Depth Unit', default='inches')
|
||||
|
||||
finished_seat_to_floor_height = fields.Float(
|
||||
string='Finished Seat to Floor Height', digits=(10, 2))
|
||||
seat_to_floor_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Seat to Floor Unit', default='inches')
|
||||
|
||||
back_cane_height = fields.Float(string='Back Cane Height', digits=(10, 2))
|
||||
cane_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Cane Height Unit', default='inches')
|
||||
|
||||
finished_back_height = fields.Float(
|
||||
string='Finished Back Height', digits=(10, 2))
|
||||
back_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Back Height Unit', default='inches')
|
||||
|
||||
finished_leg_rest_length = fields.Float(
|
||||
string='Finished Leg Rest Length', digits=(10, 2))
|
||||
leg_rest_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Leg Rest Unit', default='inches')
|
||||
|
||||
client_weight = fields.Float(string='Client Weight', digits=(10, 1))
|
||||
client_weight_unit = fields.Selection([
|
||||
('lbs', 'lbs'), ('kg', 'kg')
|
||||
], string='Weight Unit', default='lbs')
|
||||
|
||||
# =========================================================================
|
||||
# WALKER / AMBULATION AID PRESCRIPTION (Section 2a)
|
||||
# =========================================================================
|
||||
walker_seat_height = fields.Float(string='Seat Height', digits=(10, 2))
|
||||
walker_seat_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm'), ('na', 'N/A')
|
||||
], string='Seat Height Unit', default='inches')
|
||||
|
||||
push_handle_height = fields.Float(string='Push Handle Height', digits=(10, 2))
|
||||
push_handle_height_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Push Handle Height Unit', default='inches')
|
||||
|
||||
hand_grips = fields.Selection([
|
||||
('none', 'None'), ('standard', 'Standard'), ('anatomical', 'Anatomical'),
|
||||
], string='Hand Grips')
|
||||
forearm_attachments = fields.Selection([
|
||||
('one', 'One'), ('two', 'Two'),
|
||||
], string='Forearm Attachments')
|
||||
|
||||
width_between_push_handles = fields.Float(
|
||||
string='Width Between Push Handles', digits=(10, 2))
|
||||
push_handle_width_unit = fields.Selection([
|
||||
('inches', 'Inches'), ('cm', 'cm')
|
||||
], string='Push Handle Width Unit', default='inches')
|
||||
|
||||
walker_brakes = fields.Selection([
|
||||
('none', 'None'), ('push_to_lock', 'Push-To-Lock'),
|
||||
('auto_stop', 'Auto Stop'),
|
||||
], string='Brakes')
|
||||
walker_brake_type = fields.Selection([
|
||||
('none', 'None'), ('bilateral', 'Bilateral'),
|
||||
('one_hand', 'One Hand'),
|
||||
], string='Brake Type')
|
||||
walker_num_wheels = fields.Selection([
|
||||
('two', 'Two'), ('three', 'Three'), ('four', 'Four'),
|
||||
], string='Number of Wheels')
|
||||
walker_wheel_size = fields.Selection([
|
||||
('4_6', '4-6 inches'), ('6_8', '6-8 inches'), ('8_10', '8-10 inches'),
|
||||
], string='Wheel Size')
|
||||
walker_back_support = fields.Selection([
|
||||
('yes', 'Yes'), ('no', 'No'),
|
||||
], string='Back Support')
|
||||
|
||||
# Walker ADP options (checkboxes)
|
||||
walker_adolescent_wheeled_walker = fields.Boolean(
|
||||
string='Adolescent Size Paediatric Specific Wheeled Walker')
|
||||
walker_adolescent_walking_frame = fields.Boolean(
|
||||
string='Adolescent Size Paediatric Wheeled Walker – Walking Frame')
|
||||
walker_adolescent_standing_frame = fields.Boolean(
|
||||
string='Adolescent Size Paediatric Standing Frame')
|
||||
|
||||
# =========================================================================
|
||||
# POWER BASE / SCOOTER PRESCRIPTION (Section 2c)
|
||||
# =========================================================================
|
||||
# Note: power bases share seat_width, finished_back_height,
|
||||
# finished_seat_to_floor_height, finished_leg_rest_length, seat_depth,
|
||||
# and client_weight from the main measurement fields above.
|
||||
# Scooters only require client_weight (field 6).
|
||||
|
||||
# Power ADP options (checkboxes from screenshot)
|
||||
pw_adjustable_tension_back = fields.Boolean(
|
||||
string='Adjustable Tension Back Upholstery')
|
||||
pw_midline_control = fields.Boolean(string='Midline Control')
|
||||
pw_manual_recline = fields.Boolean(string='Manual Recline Option')
|
||||
pw_angle_adjustable_footplates = fields.Boolean(
|
||||
string='Angle Adjustable Footplates (pair)')
|
||||
pw_manual_elevating_legrests = fields.Boolean(
|
||||
string='Manual Elevating Legrests (pair)')
|
||||
pw_swingaway_bracket = fields.Boolean(string='Swingaway Mounting Bracket')
|
||||
pw_front_riggings = fields.Boolean(string='One Piece 90/90 Front Riggings')
|
||||
pw_seat_package_1 = fields.Boolean(
|
||||
string='Seat Package 1 for Power Bases',
|
||||
help='Includes frame, sling upholstery, armrests, footrests')
|
||||
pw_seat_package_2 = fields.Boolean(
|
||||
string='Seat Package 2 for Power Bases',
|
||||
help='Includes deluxe seat and back, armrests, footrests')
|
||||
pw_oxygen_tank = fields.Boolean(string='Oxygen Tank Holder')
|
||||
pw_ventilator_tray = fields.Boolean(string='Ventilator Tray')
|
||||
|
||||
# Specialty Components (* require clinical rationale)
|
||||
pw_specialty_1_joystick = fields.Boolean(
|
||||
string='Specialty Controls 1 Non Standard Joystick*')
|
||||
pw_specialty_2_chin = fields.Boolean(
|
||||
string='Specialty Controls 2 Chin/Rim Control*')
|
||||
pw_specialty_3_touch = fields.Boolean(
|
||||
string='Specialty Controls 3 Simple Touch*')
|
||||
pw_specialty_4_proximity = fields.Boolean(
|
||||
string='Specialty Controls 4 Proximity Control*')
|
||||
pw_specialty_5_breath = fields.Boolean(
|
||||
string='Specialty Controls 5 Breath Control*')
|
||||
pw_specialty_6_scanners = fields.Boolean(
|
||||
string='Specialty Controls 6 Scanners*')
|
||||
pw_auto_correction = fields.Boolean(string='Auto Correction System*')
|
||||
pw_specialty_rationale = fields.Text(
|
||||
string='Clinical Rationale for Specialty Components')
|
||||
|
||||
# Power Positioning Devices (require Justification for Funding Chart)
|
||||
pw_power_tilt_only = fields.Boolean(string='Power Tilt Only')
|
||||
pw_power_recline_only = fields.Boolean(string='Power Recline Only')
|
||||
pw_power_tilt_recline = fields.Boolean(string='Power Tilt and Recline')
|
||||
pw_power_elevating_footrests = fields.Boolean(
|
||||
string='Power Elevating Footrests')
|
||||
pw_multi_function_control = fields.Boolean(
|
||||
string='Multi-Function Control Box')
|
||||
pw_power_add_on = fields.Boolean(string='Power Add-On Device')
|
||||
|
||||
# Computed: convert to inches/lbs for rule evaluation
|
||||
seat_width_inches = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
seat_depth_inches = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
back_height_inches = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
client_weight_lbs = fields.Float(
|
||||
compute='_compute_normalized_measurements', store=True)
|
||||
|
||||
# =========================================================================
|
||||
# STEP 3: FRAME
|
||||
# =========================================================================
|
||||
frame_product_tmpl_id = fields.Many2one('product.template',
|
||||
string='Frame Template')
|
||||
frame_product_id = fields.Many2one('product.product',
|
||||
string='Wheelchair Frame')
|
||||
frame_notes = fields.Text(string='Frame Notes')
|
||||
|
||||
# =========================================================================
|
||||
# STEP 4 & 5: SEATING, OPTIONS, ACCESSORIES (via lines)
|
||||
# =========================================================================
|
||||
line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
|
||||
string='Selected Items')
|
||||
upcharge_line_ids = fields.One2many('fusion.wc.assessment.line', 'assessment_id',
|
||||
domain=[('is_upcharge', '=', True)], string='Auto-Applied Upcharges')
|
||||
|
||||
# =========================================================================
|
||||
# STEP 6: REVIEW & RESULT
|
||||
# =========================================================================
|
||||
sale_order_id = fields.Many2one('sale.order',
|
||||
string='Generated Quotation', readonly=True, copy=False)
|
||||
total_estimate = fields.Float(string='Total Estimate',
|
||||
compute='_compute_totals', store=True)
|
||||
total_adp_estimate = fields.Float(string='Estimated ADP Portion',
|
||||
compute='_compute_totals', store=True)
|
||||
total_client_estimate = fields.Float(string='Estimated Client Portion',
|
||||
compute='_compute_totals', store=True)
|
||||
|
||||
notes = fields.Text(string='Additional Notes')
|
||||
|
||||
# Form state for save/resume
|
||||
current_step = fields.Integer(string='Current Step', default=1)
|
||||
form_data_json = fields.Text(string='Form State (JSON)',
|
||||
help='Stores intermediate form state for save/resume')
|
||||
|
||||
# Generic equipment data (for non-wheelchair equipment types)
|
||||
equipment_data_json = fields.Text(string='Equipment Data (JSON)',
|
||||
help='JSON storage for equipment-specific field values. '
|
||||
'Used by dynamic measurement/custom steps for non-wheelchair types.')
|
||||
|
||||
# =========================================================================
|
||||
# SHARING & PUBLIC ACCESS
|
||||
# =========================================================================
|
||||
access_token = fields.Char(
|
||||
string='Access Token', copy=False, index=True,
|
||||
help='Token for public access without login')
|
||||
portal_url = fields.Char(
|
||||
string='Portal URL', compute='_compute_portal_url')
|
||||
public_url = fields.Char(
|
||||
string='Public URL', compute='_compute_public_url')
|
||||
|
||||
# =========================================================================
|
||||
# COMPUTED FIELDS
|
||||
# =========================================================================
|
||||
@api.depends('client_first_name', 'client_last_name', 'partner_id')
|
||||
def _compute_client_name(self):
|
||||
for rec in self:
|
||||
if rec.partner_id:
|
||||
rec.client_name = rec.partner_id.name
|
||||
elif rec.client_first_name or rec.client_last_name:
|
||||
parts = [rec.client_first_name or '', rec.client_last_name or '']
|
||||
rec.client_name = ' '.join(p for p in parts if p)
|
||||
else:
|
||||
rec.client_name = ''
|
||||
|
||||
@api.depends('reference', 'client_name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
parts = [rec.reference or _('New')]
|
||||
if rec.client_name:
|
||||
parts.append(rec.client_name)
|
||||
rec.display_name = ' - '.join(parts)
|
||||
|
||||
@api.depends(
|
||||
'seat_width', 'seat_width_unit',
|
||||
'seat_depth', 'seat_depth_unit',
|
||||
'finished_back_height', 'back_height_unit',
|
||||
'client_weight', 'client_weight_unit',
|
||||
)
|
||||
def _compute_normalized_measurements(self):
|
||||
"""Convert all measurements to inches/lbs for consistent rule evaluation."""
|
||||
for rec in self:
|
||||
rec.seat_width_inches = (
|
||||
rec.seat_width if rec.seat_width_unit == 'inches'
|
||||
else rec.seat_width / 2.54
|
||||
)
|
||||
rec.seat_depth_inches = (
|
||||
rec.seat_depth if rec.seat_depth_unit == 'inches'
|
||||
else rec.seat_depth / 2.54
|
||||
)
|
||||
rec.back_height_inches = (
|
||||
rec.finished_back_height if rec.back_height_unit == 'inches'
|
||||
else rec.finished_back_height / 2.54
|
||||
)
|
||||
rec.client_weight_lbs = (
|
||||
rec.client_weight if rec.client_weight_unit == 'lbs'
|
||||
else rec.client_weight * 2.20462
|
||||
)
|
||||
|
||||
@api.depends('line_ids.subtotal', 'line_ids.adp_portion', 'line_ids.client_portion')
|
||||
def _compute_totals(self):
|
||||
for rec in self:
|
||||
rec.total_estimate = sum(rec.line_ids.mapped('subtotal'))
|
||||
rec.total_adp_estimate = sum(rec.line_ids.mapped('adp_portion'))
|
||||
rec.total_client_estimate = sum(rec.line_ids.mapped('client_portion'))
|
||||
|
||||
def _compute_portal_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
for rec in self:
|
||||
rec.portal_url = f'{base}/my/quotation/builder/{rec.id}/edit' if rec.id else False
|
||||
|
||||
def _compute_public_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
for rec in self:
|
||||
if rec.access_token:
|
||||
rec.public_url = f'{base}/quotation/form/{rec.access_token}'
|
||||
else:
|
||||
rec.public_url = False
|
||||
|
||||
# =========================================================================
|
||||
# SHARING ACTIONS
|
||||
# =========================================================================
|
||||
def _generate_access_token(self):
|
||||
"""Generate a unique access token for public sharing."""
|
||||
for rec in self:
|
||||
if not rec.access_token:
|
||||
rec.access_token = uuid.uuid4().hex
|
||||
|
||||
def action_open_portal_form(self):
|
||||
"""Open the portal assessment form in a new browser tab."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': self.portal_url,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_generate_share_link(self):
|
||||
"""Generate a public access token and show the shareable URL."""
|
||||
self.ensure_one()
|
||||
self._generate_access_token()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Share Link Generated'),
|
||||
'message': _('Public URL copied to clipboard — '
|
||||
'also visible in the Sharing section of this form.'),
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# CRUD OVERRIDES
|
||||
# =========================================================================
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('reference', _('New')) == _('New'):
|
||||
vals['reference'] = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.wc.assessment') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
# =========================================================================
|
||||
# UPCHARGE RULE ENGINE
|
||||
# =========================================================================
|
||||
def _evaluate_upcharge_rules(self):
|
||||
"""Evaluate all active upcharge rules against current assessment data.
|
||||
Creates assessment lines for triggered rules.
|
||||
Returns recordset of triggered rules.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Remove previously auto-applied upcharges (allows re-evaluation)
|
||||
self.line_ids.filtered(lambda l: l.is_upcharge).unlink()
|
||||
|
||||
rules = self.env['fusion.wc.upcharge.rule'].search([
|
||||
('active', '=', True),
|
||||
('equipment_type', 'in', [self.equipment_type, 'both']),
|
||||
])
|
||||
|
||||
triggered = self.env['fusion.wc.upcharge.rule']
|
||||
# Track exclusive groups — only the first matching rule per group applies
|
||||
exclusive_triggered = {}
|
||||
|
||||
for rule in rules.sorted('sequence'):
|
||||
# Check mutual exclusion
|
||||
if rule.mutually_exclusive_group:
|
||||
if rule.mutually_exclusive_group in exclusive_triggered:
|
||||
continue
|
||||
|
||||
matched = False
|
||||
|
||||
if rule.trigger_type == 'measurement':
|
||||
value = self._get_measurement_value(rule.measurement_field)
|
||||
if value and self._compare_values(value, rule.comparison, rule.threshold_value):
|
||||
matched = True
|
||||
|
||||
elif rule.trigger_type == 'weight':
|
||||
weight = self.client_weight_lbs
|
||||
if weight > rule.weight_min:
|
||||
if not rule.weight_max or weight <= rule.weight_max:
|
||||
matched = True
|
||||
|
||||
elif rule.trigger_type == 'dimension_mismatch':
|
||||
val1 = self._get_measurement_value(rule.compare_field_1)
|
||||
val2 = self._get_measurement_value(rule.compare_field_2)
|
||||
if val1 and val2 and abs(val1 - val2) > 0.01:
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
self._create_upcharge_line(rule)
|
||||
triggered |= rule
|
||||
if rule.mutually_exclusive_group:
|
||||
exclusive_triggered[rule.mutually_exclusive_group] = rule
|
||||
|
||||
return triggered
|
||||
|
||||
def _get_measurement_value(self, field_name):
|
||||
"""Map measurement field selection to actual normalized (inches) value."""
|
||||
field_map = {
|
||||
'seat_width': self.seat_width_inches,
|
||||
'seat_depth': self.seat_depth_inches,
|
||||
'back_width': self.seat_width_inches, # backrest width defaults to seat width
|
||||
'back_height': self.back_height_inches,
|
||||
'seat_to_floor': (
|
||||
self.finished_seat_to_floor_height
|
||||
if self.seat_to_floor_unit == 'inches'
|
||||
else self.finished_seat_to_floor_height / 2.54
|
||||
),
|
||||
'leg_rest_length': (
|
||||
self.finished_leg_rest_length
|
||||
if self.leg_rest_unit == 'inches'
|
||||
else self.finished_leg_rest_length / 2.54
|
||||
),
|
||||
}
|
||||
return field_map.get(field_name, 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _compare_values(value, comparison, threshold):
|
||||
"""Evaluate a comparison between value and threshold."""
|
||||
if comparison == 'gt':
|
||||
return value > threshold
|
||||
elif comparison == 'gte':
|
||||
return value >= threshold
|
||||
elif comparison == 'lt':
|
||||
return value < threshold
|
||||
elif comparison == 'eq':
|
||||
return abs(value - threshold) < 0.01
|
||||
elif comparison == 'neq':
|
||||
return abs(value - threshold) >= 0.01
|
||||
return False
|
||||
|
||||
def _create_upcharge_line(self, rule):
|
||||
"""Create an assessment line from an upcharge rule."""
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
adp_device = ADPDevice.search(
|
||||
[('device_code', '=', rule.adp_device_code), ('active', '=', True)],
|
||||
limit=1
|
||||
)
|
||||
|
||||
vals = {
|
||||
'assessment_id': self.id,
|
||||
'product_id': rule.product_id.id if rule.product_id else False,
|
||||
'product_name': (
|
||||
adp_device.device_description
|
||||
if adp_device else rule.name
|
||||
),
|
||||
'quantity': 1,
|
||||
'adp_device_code': rule.adp_device_code,
|
||||
'adp_price': adp_device.adp_price if adp_device else 0,
|
||||
'unit_price': adp_device.adp_price if adp_device else 0,
|
||||
'is_upcharge': True,
|
||||
'upcharge_rule_id': rule.id,
|
||||
'upcharge_reason': rule.description or rule.name,
|
||||
}
|
||||
return self.env['fusion.wc.assessment.line'].create(vals)
|
||||
|
||||
# =========================================================================
|
||||
# QUOTATION GENERATION
|
||||
# =========================================================================
|
||||
def action_generate_quotation(self):
|
||||
"""Generate a draft sale.order from this assessment."""
|
||||
self.ensure_one()
|
||||
if self.sale_order_id:
|
||||
raise UserError(_('A quotation has already been generated for this assessment.'))
|
||||
|
||||
# Run upcharge engine first
|
||||
triggered_rules = self._evaluate_upcharge_rules()
|
||||
|
||||
# Resolve or create partner
|
||||
partner = self._resolve_partner()
|
||||
|
||||
# Add frame as an assessment line if not already present
|
||||
self._ensure_frame_line()
|
||||
|
||||
# Create sale order
|
||||
so_vals = self._prepare_sale_order_vals(partner)
|
||||
sale_order = self.env['sale.order'].sudo().create(so_vals)
|
||||
|
||||
# Create sale order lines from assessment lines
|
||||
for line in self.line_ids:
|
||||
sol_vals = self._prepare_sale_order_line_vals(sale_order, line)
|
||||
self.env['sale.order.line'].sudo().create(sol_vals)
|
||||
|
||||
# Update assessment
|
||||
self.write({
|
||||
'sale_order_id': sale_order.id,
|
||||
'state': 'quoted',
|
||||
})
|
||||
|
||||
# Post chatter message
|
||||
self._post_quotation_message(sale_order, triggered_rules)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': sale_order.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _resolve_partner(self):
|
||||
"""Get existing partner or create a new one."""
|
||||
self.ensure_one()
|
||||
if self.partner_id:
|
||||
return self.partner_id
|
||||
|
||||
if not self.client_first_name and not self.client_last_name:
|
||||
raise UserError(_('Please select an existing client or provide client name.'))
|
||||
|
||||
partner_vals = {
|
||||
'name': self.client_name,
|
||||
'phone': self.client_phone,
|
||||
'email': self.client_email,
|
||||
'street': self.client_street,
|
||||
'street2': self.client_street2,
|
||||
'city': self.client_city,
|
||||
'state_id': self.client_state_id.id if self.client_state_id else False,
|
||||
'zip': self.client_zip,
|
||||
'country_id': self.client_country_id.id if self.client_country_id else False,
|
||||
'customer_rank': 1,
|
||||
}
|
||||
partner = self.env['res.partner'].sudo().create(partner_vals)
|
||||
self.partner_id = partner
|
||||
return partner
|
||||
|
||||
def _ensure_frame_line(self):
|
||||
"""Add the frame product as an assessment line if not already present."""
|
||||
self.ensure_one()
|
||||
|
||||
# If we have a template but no resolved variant, resolve to first variant
|
||||
if not self.frame_product_id and self.frame_product_tmpl_id:
|
||||
variant = self.frame_product_tmpl_id.product_variant_ids[:1]
|
||||
if variant:
|
||||
self.frame_product_id = variant.id
|
||||
|
||||
if not self.frame_product_id:
|
||||
return
|
||||
|
||||
# Pick the right frame section based on equipment type
|
||||
frame_code_map = {
|
||||
'manual_wheelchair': 'mw_frame',
|
||||
'power_wheelchair': 'pw_frame',
|
||||
'walker': 'walker_frame',
|
||||
}
|
||||
code = frame_code_map.get(self.equipment_type, 'mw_frame')
|
||||
frame_section = self.env['fusion.wc.section'].search(
|
||||
[('code', '=', code)], limit=1)
|
||||
if not frame_section:
|
||||
# Fall back to legacy code
|
||||
frame_section = self.env['fusion.wc.section'].search(
|
||||
[('code', '=', 'frame')], limit=1)
|
||||
|
||||
existing = self.line_ids.filtered(
|
||||
lambda l: l.product_id == self.frame_product_id and not l.is_upcharge)
|
||||
if existing:
|
||||
return
|
||||
|
||||
# Build description from measurements
|
||||
desc_parts = [self.frame_product_id.display_name]
|
||||
if self.seat_width:
|
||||
desc_parts.append(f"Seat Width: {self.seat_width}{self.seat_width_unit}")
|
||||
if self.seat_depth:
|
||||
desc_parts.append(f"Seat Depth: {self.seat_depth}{self.seat_depth_unit}")
|
||||
if self.finished_seat_to_floor_height:
|
||||
desc_parts.append(
|
||||
f"Seat to Floor: {self.finished_seat_to_floor_height}"
|
||||
f"{self.seat_to_floor_unit}")
|
||||
if self.finished_leg_rest_length:
|
||||
desc_parts.append(
|
||||
f"Leg Rest: {self.finished_leg_rest_length}{self.leg_rest_unit}")
|
||||
if self.back_cane_height:
|
||||
desc_parts.append(
|
||||
f"Cane Height: {self.back_cane_height}{self.cane_height_unit}")
|
||||
|
||||
product = self.frame_product_id
|
||||
adp_code = product.product_tmpl_id.x_fc_adp_device_code or ''
|
||||
adp_price = product.product_tmpl_id.x_fc_adp_price or product.lst_price
|
||||
|
||||
self.env['fusion.wc.assessment.line'].create({
|
||||
'assessment_id': self.id,
|
||||
'section_id': frame_section.id if frame_section else False,
|
||||
'product_id': product.id,
|
||||
'product_name': '\n'.join(desc_parts),
|
||||
'quantity': 1,
|
||||
'adp_device_code': adp_code,
|
||||
'adp_price': adp_price,
|
||||
'unit_price': product.lst_price,
|
||||
})
|
||||
|
||||
def _prepare_sale_order_vals(self, partner):
|
||||
"""Prepare values for sale.order creation."""
|
||||
self.ensure_one()
|
||||
|
||||
# Map client_type to fusion_claims x_fc_client_type
|
||||
client_type_map = {
|
||||
'reg': 'REG',
|
||||
'ods': 'ODS',
|
||||
'acs': 'ACS',
|
||||
'owp': 'OWP',
|
||||
'ltc': 'LTC',
|
||||
'sen': 'SEN',
|
||||
'cca': 'CCA',
|
||||
}
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'user_id': self.sales_rep_id.id,
|
||||
'state': 'draft',
|
||||
'origin': f'WC Assessment: {self.reference}',
|
||||
'wc_assessment_id': self.id,
|
||||
}
|
||||
|
||||
# Set fusion_claims fields if available
|
||||
if hasattr(self.env['sale.order'], 'x_fc_sale_type'):
|
||||
vals['x_fc_sale_type'] = 'adp'
|
||||
if hasattr(self.env['sale.order'], 'x_fc_client_type'):
|
||||
vals['x_fc_client_type'] = client_type_map.get(self.client_type, 'REG')
|
||||
if hasattr(self.env['sale.order'], 'x_fc_adp_application_status'):
|
||||
vals['x_fc_adp_application_status'] = 'quotation'
|
||||
if self.authorizer_id and hasattr(self.env['sale.order'], 'x_fc_authorizer_id'):
|
||||
vals['x_fc_authorizer_id'] = self.authorizer_id.id
|
||||
|
||||
return vals
|
||||
|
||||
def _prepare_sale_order_line_vals(self, sale_order, line):
|
||||
"""Prepare values for sale.order.line creation from assessment line."""
|
||||
vals = {
|
||||
'order_id': sale_order.id,
|
||||
'product_id': line.product_id.id if line.product_id else False,
|
||||
'name': line.product_name or (
|
||||
line.product_id.display_name if line.product_id else line.adp_device_code
|
||||
),
|
||||
'product_uom_qty': line.quantity,
|
||||
'price_unit': line.unit_price or (
|
||||
line.product_id.lst_price if line.product_id else 0
|
||||
),
|
||||
}
|
||||
return vals
|
||||
|
||||
def _post_quotation_message(self, sale_order, triggered_rules):
|
||||
"""Post a chatter message summarizing the assessment."""
|
||||
self.ensure_one()
|
||||
|
||||
lines_html = ''
|
||||
for line in self.line_ids:
|
||||
icon = '⚡' if line.is_upcharge else '✅'
|
||||
lines_html += (
|
||||
f'<li>{icon} {line.product_name or line.adp_device_code} '
|
||||
f'x{line.quantity} — ${line.unit_price:.2f}</li>'
|
||||
)
|
||||
|
||||
measurements_html = ''
|
||||
if self.seat_width:
|
||||
measurements_html += (
|
||||
f'<li>Seat Width: {self.seat_width} {self.seat_width_unit}</li>')
|
||||
if self.seat_depth:
|
||||
measurements_html += (
|
||||
f'<li>Seat Depth: {self.seat_depth} {self.seat_depth_unit}</li>')
|
||||
if self.finished_seat_to_floor_height:
|
||||
measurements_html += (
|
||||
f'<li>Seat to Floor: {self.finished_seat_to_floor_height} '
|
||||
f'{self.seat_to_floor_unit}</li>')
|
||||
if self.back_cane_height:
|
||||
measurements_html += (
|
||||
f'<li>Back Cane Height: {self.back_cane_height} '
|
||||
f'{self.cane_height_unit}</li>')
|
||||
if self.finished_back_height:
|
||||
measurements_html += (
|
||||
f'<li>Finished Back Height: {self.finished_back_height} '
|
||||
f'{self.back_height_unit}</li>')
|
||||
if self.finished_leg_rest_length:
|
||||
measurements_html += (
|
||||
f'<li>Leg Rest Length: {self.finished_leg_rest_length} '
|
||||
f'{self.leg_rest_unit}</li>')
|
||||
if self.client_weight:
|
||||
measurements_html += (
|
||||
f'<li>Client Weight: {self.client_weight} {self.client_weight_unit}</li>')
|
||||
|
||||
upcharges_html = ''
|
||||
if triggered_rules:
|
||||
upcharges_html = '<h4>Auto-Applied Upcharges</h4><ul>'
|
||||
for rule in triggered_rules:
|
||||
upcharges_html += (
|
||||
f'<li>⚡ {rule.name} ({rule.adp_device_code})</li>')
|
||||
upcharges_html += '</ul>'
|
||||
|
||||
body = Markup(
|
||||
f'<h3>Wheelchair Assessment Completed</h3>'
|
||||
f'<p>Assessment: {self.reference}<br/>'
|
||||
f'Equipment: {dict(self._fields["equipment_type"].selection).get(self.equipment_type, "")}<br/>'
|
||||
f'Build Type: {dict(self._fields["build_type"].selection).get(self.build_type, "")}</p>'
|
||||
f'<h4>Measurements</h4><ul>{measurements_html}</ul>'
|
||||
f'<h4>Selected Items</h4><ul>{lines_html}</ul>'
|
||||
f'{upcharges_html}'
|
||||
)
|
||||
|
||||
sale_order.message_post(body=body, message_type='comment',
|
||||
subtype_xmlid='mail.mt_note')
|
||||
|
||||
# =========================================================================
|
||||
# ACTIONS
|
||||
# =========================================================================
|
||||
def action_mark_review(self):
|
||||
"""Mark assessment as ready for review."""
|
||||
for rec in self:
|
||||
rec.state = 'review'
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel the assessment."""
|
||||
for rec in self:
|
||||
rec.state = 'cancelled'
|
||||
|
||||
def action_reset_draft(self):
|
||||
"""Reset to draft."""
|
||||
for rec in self:
|
||||
if rec.state == 'cancelled':
|
||||
rec.state = 'draft'
|
||||
|
||||
def action_view_quotation(self):
|
||||
"""Open the generated quotation."""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
raise UserError(_('No quotation has been generated yet.'))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairAssessmentLine(models.Model):
|
||||
_name = 'fusion.wc.assessment.line'
|
||||
_description = 'Wheelchair Assessment Line Item'
|
||||
_order = 'section_sequence, sequence, id'
|
||||
|
||||
assessment_id = fields.Many2one('fusion.wc.assessment', string='Assessment',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section', index=True)
|
||||
section_sequence = fields.Integer(
|
||||
related='section_id.sequence', store=True, string='Section Order')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
# Product
|
||||
product_id = fields.Many2one('product.product', string='Product', index=True)
|
||||
product_name = fields.Char(string='Description',
|
||||
help='Can override product name with custom description')
|
||||
quantity = fields.Float(string='Quantity', default=1.0)
|
||||
|
||||
# ADP info
|
||||
adp_device_code = fields.Char(string='ADP Code')
|
||||
adp_price = fields.Float(string='ADP Price', digits='Product Price')
|
||||
unit_price = fields.Float(string='Selling Price', digits='Product Price')
|
||||
|
||||
# Build type (for seating items)
|
||||
build_type = fields.Selection([
|
||||
('modular', 'Modular'),
|
||||
('custom_fabricated', 'Custom Fabricated'),
|
||||
], string='Build Type')
|
||||
|
||||
# Clinical rationale (required for certain items)
|
||||
clinical_rationale = fields.Text(string='Clinical Rationale')
|
||||
|
||||
# Computed pricing
|
||||
subtotal = fields.Float(string='Subtotal',
|
||||
compute='_compute_subtotal', store=True, digits='Product Price')
|
||||
adp_portion = fields.Float(string='ADP Portion',
|
||||
compute='_compute_portions', store=True, digits='Product Price')
|
||||
client_portion = fields.Float(string='Client Portion',
|
||||
compute='_compute_portions', store=True, digits='Product Price')
|
||||
|
||||
# Upcharge tracking
|
||||
is_upcharge = fields.Boolean(string='Auto-Applied Upcharge', default=False)
|
||||
upcharge_rule_id = fields.Many2one('fusion.wc.upcharge.rule',
|
||||
string='Triggered By Rule')
|
||||
upcharge_reason = fields.Char(string='Upcharge Reason')
|
||||
|
||||
# Section-specific measurements
|
||||
width = fields.Float(string='Width', digits=(10, 2))
|
||||
depth = fields.Float(string='Depth', digits=(10, 2))
|
||||
height = fields.Float(string='Height', digits=(10, 2))
|
||||
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
@api.depends('unit_price', 'quantity')
|
||||
def _compute_subtotal(self):
|
||||
for line in self:
|
||||
line.subtotal = line.unit_price * line.quantity
|
||||
|
||||
@api.depends('subtotal', 'adp_price', 'quantity', 'assessment_id.client_type')
|
||||
def _compute_portions(self):
|
||||
"""Estimate ADP and client portions based on client type and ADP price."""
|
||||
for line in self:
|
||||
if not line.adp_price or not line.subtotal:
|
||||
line.adp_portion = 0.0
|
||||
line.client_portion = line.subtotal
|
||||
continue
|
||||
|
||||
client_type = line.assessment_id.client_type or 'reg'
|
||||
adp_base = line.adp_price * line.quantity
|
||||
|
||||
# Determine ADP coverage percentage
|
||||
if client_type == 'reg':
|
||||
# REG: 75% ADP, 25% client
|
||||
adp_amount = adp_base * 0.75
|
||||
else:
|
||||
# ODS, ACS, OWP, etc.: 100% ADP
|
||||
adp_amount = adp_base
|
||||
|
||||
# Client pays the rest (including anything above ADP price)
|
||||
line.adp_portion = min(adp_amount, line.subtotal)
|
||||
line.client_portion = line.subtotal - line.adp_portion
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
"""Auto-fill ADP code and pricing from product."""
|
||||
if self.product_id:
|
||||
tmpl = self.product_id.product_tmpl_id
|
||||
self.adp_device_code = tmpl.x_fc_adp_device_code or ''
|
||||
self.adp_price = tmpl.x_fc_adp_price or 0.0
|
||||
self.unit_price = self.product_id.lst_price
|
||||
if not self.product_name:
|
||||
self.product_name = self.product_id.display_name
|
||||
539
Work in Progress/fusion_quotations/models/wc_config_flow.py
Normal file
539
Work in Progress/fusion_quotations/models/wc_config_flow.py
Normal file
@@ -0,0 +1,539 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class WheelchairConfigFlow(models.Model):
|
||||
_name = 'fusion.wc.config.flow'
|
||||
_description = 'Wheelchair Configuration Flow'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True,
|
||||
help='e.g. "Standard Manual Wheelchair Config"')
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', required=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
return [(t.code, t.name) for t in types]
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair'),
|
||||
('walker', 'Walker / Ambulation Aid'),
|
||||
]
|
||||
|
||||
description = fields.Text(string='Description')
|
||||
|
||||
# Canvas state — JSON blob for viewport (zoom, pan) preserved across sessions
|
||||
canvas_data = fields.Text(string='Canvas Data', default='{}',
|
||||
help='JSON: viewport state for the visual designer')
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('archived', 'Archived'),
|
||||
], string='Status', default='draft', tracking=True)
|
||||
|
||||
# Relationships
|
||||
node_ids = fields.One2many('fusion.wc.config.flow.node', 'flow_id',
|
||||
string='Nodes')
|
||||
connection_ids = fields.One2many('fusion.wc.config.flow.connection', 'flow_id',
|
||||
string='Connections')
|
||||
step_ids = fields.One2many('fusion.wc.config.flow.step', 'flow_id',
|
||||
string='Form Steps')
|
||||
|
||||
# Computed counts
|
||||
node_count = fields.Integer(string='Nodes', compute='_compute_counts')
|
||||
connection_count = fields.Integer(string='Connections', compute='_compute_counts')
|
||||
step_count = fields.Integer(string='Steps', compute='_compute_counts')
|
||||
|
||||
@api.depends('node_ids', 'connection_ids', 'step_ids')
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
rec.node_count = len(rec.node_ids)
|
||||
rec.connection_count = len(rec.connection_ids)
|
||||
rec.step_count = len(rec.step_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_open_designer(self):
|
||||
"""Open the visual flow designer (OWL client action)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_flow_designer',
|
||||
'name': _('Flow Designer: %s') % self.name,
|
||||
'context': {'active_id': self.id},
|
||||
}
|
||||
|
||||
def action_activate(self):
|
||||
"""Set flow to active. Deactivate other flows for same equipment type.
|
||||
Auto-creates default steps if flow has no steps defined.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Auto-create default steps if none exist
|
||||
if not self.step_ids:
|
||||
self.action_create_default_steps()
|
||||
# Deactivate other active flows for the same equipment type
|
||||
siblings = self.search([
|
||||
('equipment_type', '=', self.equipment_type),
|
||||
('state', '=', 'active'),
|
||||
('id', '!=', self.id),
|
||||
])
|
||||
siblings.write({'state': 'draft'})
|
||||
self.write({'state': 'active'})
|
||||
|
||||
def action_archive(self):
|
||||
self.ensure_one()
|
||||
self.write({'state': 'archived'})
|
||||
|
||||
def action_reset_draft(self):
|
||||
self.ensure_one()
|
||||
self.write({'state': 'draft'})
|
||||
|
||||
def action_create_default_steps(self):
|
||||
"""Generate default form steps based on equipment type.
|
||||
For wheelchair types, creates the standard 6 steps.
|
||||
For other types, creates a generic 4-step flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Step = self.env['fusion.wc.config.flow.step']
|
||||
|
||||
# Remove existing steps
|
||||
self.step_ids.unlink()
|
||||
|
||||
wheelchair_types = ('manual_wheelchair', 'power_wheelchair')
|
||||
if self.equipment_type in wheelchair_types:
|
||||
# ── Client step config: ADP program fields ──
|
||||
is_manual = self.equipment_type == 'manual_wheelchair'
|
||||
client_config = json.dumps({
|
||||
"show_health_card": True,
|
||||
"show_dob": True,
|
||||
"show_adp_fields": True, # client_type + reason_for_application
|
||||
"show_wheelchair_category": is_manual,
|
||||
"show_powerchair_category": not is_manual,
|
||||
})
|
||||
# ── Measurement fields ──
|
||||
mw_measurements_json = json.dumps([
|
||||
{"name": "seat_width", "label": "Seat Width", "type": "float",
|
||||
"unit_field": "seat_width_unit", "units": ["inches", "cm"], "required": True},
|
||||
{"name": "seat_depth", "label": "Seat Depth", "type": "float",
|
||||
"unit_field": "seat_depth_unit", "units": ["inches", "cm"], "required": True},
|
||||
{"name": "finished_seat_to_floor_height", "label": "Finished Seat to Floor Height",
|
||||
"type": "float", "unit_field": "seat_to_floor_unit", "units": ["inches", "cm"]},
|
||||
{"name": "back_cane_height", "label": "Back Cane Height", "type": "float",
|
||||
"unit_field": "cane_height_unit", "units": ["inches", "cm"]},
|
||||
{"name": "finished_back_height", "label": "Finished Back Height", "type": "float",
|
||||
"unit_field": "back_height_unit", "units": ["inches", "cm"]},
|
||||
{"name": "finished_leg_rest_length", "label": "Finished Leg Rest Length",
|
||||
"type": "float", "unit_field": "leg_rest_unit", "units": ["inches", "cm"]},
|
||||
{"name": "client_weight", "label": "Client Weight", "type": "float",
|
||||
"unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True},
|
||||
])
|
||||
prefix = 'mw' if is_manual else 'pw'
|
||||
steps = [
|
||||
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
|
||||
'icon': 'fa-user', 'fields_json': client_config},
|
||||
{'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements',
|
||||
'icon': 'fa-ruler', 'fields_json': mw_measurements_json},
|
||||
{'sequence': 30, 'name': 'Frame', 'step_type': 'product_select',
|
||||
'icon': 'fa-wheelchair', 'section_code': f'{prefix}_frame'},
|
||||
{'sequence': 40, 'name': 'Seating', 'step_type': 'options',
|
||||
'icon': 'fa-chair', 'section_code': 'seating'},
|
||||
{'sequence': 50, 'name': 'Options', 'step_type': 'options',
|
||||
'icon': 'fa-list', 'section_code': f'{prefix}_adp_options,{prefix}_accessories'},
|
||||
{'sequence': 60, 'name': 'Review', 'step_type': 'review',
|
||||
'icon': 'fa-check'},
|
||||
]
|
||||
elif self.equipment_type == 'walker':
|
||||
# ── Client step config: ADP program fields (no wheelchair categories) ──
|
||||
client_config = json.dumps({
|
||||
"show_health_card": True,
|
||||
"show_dob": True,
|
||||
"show_adp_fields": True,
|
||||
})
|
||||
walker_measurements_json = json.dumps([
|
||||
{"name": "walker_seat_height", "label": "Seat Height", "type": "float",
|
||||
"unit_field": "walker_seat_height_unit", "units": ["inches", "cm", "na"]},
|
||||
{"name": "push_handle_height", "label": "Push Handle Height", "type": "float",
|
||||
"unit_field": "push_handle_height_unit", "units": ["inches", "cm"]},
|
||||
{"name": "width_between_push_handles", "label": "Width Between Push Handles",
|
||||
"type": "float", "unit_field": "push_handle_width_unit", "units": ["inches", "cm"]},
|
||||
{"name": "client_weight", "label": "Client Weight", "type": "float",
|
||||
"unit_field": "client_weight_unit", "units": ["lbs", "kg"], "required": True},
|
||||
])
|
||||
steps = [
|
||||
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
|
||||
'icon': 'fa-user', 'fields_json': client_config},
|
||||
{'sequence': 20, 'name': 'Measurements', 'step_type': 'measurements',
|
||||
'icon': 'fa-ruler', 'fields_json': walker_measurements_json},
|
||||
{'sequence': 30, 'name': 'Equipment', 'step_type': 'product_select',
|
||||
'icon': 'fa-male', 'section_code': 'walker_frame'},
|
||||
{'sequence': 40, 'name': 'Options', 'step_type': 'options',
|
||||
'icon': 'fa-list', 'section_code': 'walker_adp_options,walker_accessories'},
|
||||
{'sequence': 50, 'name': 'Review', 'step_type': 'review',
|
||||
'icon': 'fa-check'},
|
||||
]
|
||||
else:
|
||||
# ── Generic flow for new equipment types (stair lift, porch lift, etc.) ──
|
||||
# No ADP fields, no health card, no DOB — pure quotation tool
|
||||
steps = [
|
||||
{'sequence': 10, 'name': 'Client', 'step_type': 'client_info',
|
||||
'icon': 'fa-user'}, # no fields_json → no optional groups
|
||||
{'sequence': 20, 'name': 'Equipment', 'step_type': 'product_select',
|
||||
'icon': 'fa-cog'},
|
||||
{'sequence': 30, 'name': 'Options', 'step_type': 'options',
|
||||
'icon': 'fa-list'},
|
||||
{'sequence': 40, 'name': 'Review', 'step_type': 'review',
|
||||
'icon': 'fa-check'},
|
||||
]
|
||||
|
||||
for step_vals in steps:
|
||||
step_vals['flow_id'] = self.id
|
||||
Step.create(step_vals)
|
||||
|
||||
def action_new_assessment_form(self):
|
||||
"""Open a new portal assessment form pre-selected to this flow's equipment type."""
|
||||
self.ensure_one()
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'{base}/my/quotation/builder/new?equipment_type={self.equipment_type}',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Load / Save graph for designer
|
||||
# ------------------------------------------------------------------
|
||||
def load_flow_graph(self):
|
||||
"""Return the complete flow graph for the visual designer."""
|
||||
self.ensure_one()
|
||||
nodes = []
|
||||
for n in self.node_ids:
|
||||
node_data = {
|
||||
'id': n.id,
|
||||
'name': n.name,
|
||||
'node_type': n.node_type,
|
||||
'pos_x': n.pos_x,
|
||||
'pos_y': n.pos_y,
|
||||
'color': n.color,
|
||||
'icon': n.icon,
|
||||
'section_id': n.section_id.id if n.section_id else False,
|
||||
'section_name': n.section_id.name if n.section_id else '',
|
||||
'decision_field': n.decision_field or '',
|
||||
'decision_operator': n.decision_operator or '',
|
||||
'decision_value': n.decision_value or '',
|
||||
'measurement_field': n.measurement_field or '',
|
||||
'comparison': n.comparison or '',
|
||||
'threshold_value': n.threshold_value,
|
||||
'action_type': n.action_type or '',
|
||||
'target_option_ids': n.target_option_ids.ids,
|
||||
'target_step': n.target_step,
|
||||
'config_json': n.config_json or '{}',
|
||||
'node_options': [{
|
||||
'id': opt.id,
|
||||
'name': opt.name,
|
||||
'sequence': opt.sequence,
|
||||
'section_option_id': opt.section_option_id.id if opt.section_option_id else False,
|
||||
'enables_option_ids': opt.enables_option_ids.ids,
|
||||
'disables_option_ids': opt.disables_option_ids.ids,
|
||||
'requires_option_ids': opt.requires_option_ids.ids,
|
||||
'port_key': opt.port_key,
|
||||
} for opt in n.node_option_ids],
|
||||
}
|
||||
nodes.append(node_data)
|
||||
|
||||
connections = []
|
||||
for c in self.connection_ids:
|
||||
connections.append({
|
||||
'id': c.id,
|
||||
'source_node_id': c.source_node_id.id,
|
||||
'target_node_id': c.target_node_id.id,
|
||||
'source_port': c.source_port or 'out',
|
||||
'label': c.label or '',
|
||||
'condition_json': c.condition_json or '{}',
|
||||
'sequence': c.sequence,
|
||||
})
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'equipment_type': self.equipment_type,
|
||||
'canvas': json.loads(self.canvas_data or '{}'),
|
||||
'nodes': nodes,
|
||||
'connections': connections,
|
||||
}
|
||||
|
||||
def save_flow_graph(self, graph_data):
|
||||
"""Sync the full graph from the visual designer to ORM records."""
|
||||
self.ensure_one()
|
||||
Node = self.env['fusion.wc.config.flow.node']
|
||||
Connection = self.env['fusion.wc.config.flow.connection']
|
||||
|
||||
# Save viewport state
|
||||
canvas = graph_data.get('canvas', {})
|
||||
self.write({'canvas_data': json.dumps(canvas)})
|
||||
|
||||
# --- Sync Nodes ---
|
||||
incoming_nodes = graph_data.get('nodes', [])
|
||||
incoming_node_ids = set()
|
||||
node_id_map = {} # temp_id -> real_id
|
||||
|
||||
for ndata in incoming_nodes:
|
||||
vals = {
|
||||
'flow_id': self.id,
|
||||
'name': ndata.get('name', 'Untitled'),
|
||||
'node_type': ndata.get('node_type', 'action'),
|
||||
'pos_x': ndata.get('pos_x', 0),
|
||||
'pos_y': ndata.get('pos_y', 0),
|
||||
'color': ndata.get('color', '#3b82f6'),
|
||||
'icon': ndata.get('icon', 'fa-circle'),
|
||||
'section_id': ndata.get('section_id') or False,
|
||||
'decision_field': ndata.get('decision_field') or False,
|
||||
'decision_operator': ndata.get('decision_operator') or False,
|
||||
'decision_value': ndata.get('decision_value') or '',
|
||||
'measurement_field': ndata.get('measurement_field') or False,
|
||||
'comparison': ndata.get('comparison') or False,
|
||||
'threshold_value': ndata.get('threshold_value', 0),
|
||||
'action_type': ndata.get('action_type') or False,
|
||||
'target_step': ndata.get('target_step', 0),
|
||||
'config_json': ndata.get('config_json', '{}'),
|
||||
}
|
||||
target_ids = ndata.get('target_option_ids', [])
|
||||
|
||||
node_id = ndata.get('id')
|
||||
if isinstance(node_id, int) and node_id > 0:
|
||||
# Update existing
|
||||
node = Node.browse(node_id)
|
||||
if node.exists():
|
||||
node.write(vals)
|
||||
if target_ids is not None:
|
||||
node.write({'target_option_ids': [(6, 0, target_ids)]})
|
||||
node_id_map[node_id] = node_id
|
||||
incoming_node_ids.add(node_id)
|
||||
continue
|
||||
# Create new
|
||||
new_node = Node.create(vals)
|
||||
if target_ids:
|
||||
new_node.write({'target_option_ids': [(6, 0, target_ids)]})
|
||||
node_id_map[ndata.get('id', 'new')] = new_node.id
|
||||
incoming_node_ids.add(new_node.id)
|
||||
|
||||
# Delete nodes no longer in the graph
|
||||
existing_nodes = Node.search([('flow_id', '=', self.id)])
|
||||
to_delete = existing_nodes.filtered(lambda n: n.id not in incoming_node_ids)
|
||||
to_delete.unlink()
|
||||
|
||||
# --- Sync Connections ---
|
||||
incoming_conns = graph_data.get('connections', [])
|
||||
incoming_conn_ids = set()
|
||||
|
||||
for cdata in incoming_conns:
|
||||
src_id = cdata.get('source_node_id')
|
||||
tgt_id = cdata.get('target_node_id')
|
||||
# Resolve temp IDs
|
||||
src_id = node_id_map.get(src_id, src_id)
|
||||
tgt_id = node_id_map.get(tgt_id, tgt_id)
|
||||
|
||||
vals = {
|
||||
'flow_id': self.id,
|
||||
'source_node_id': src_id,
|
||||
'target_node_id': tgt_id,
|
||||
'source_port': cdata.get('source_port', 'out'),
|
||||
'label': cdata.get('label', ''),
|
||||
'condition_json': cdata.get('condition_json', '{}'),
|
||||
'sequence': cdata.get('sequence', 10),
|
||||
}
|
||||
|
||||
conn_id = cdata.get('id')
|
||||
if isinstance(conn_id, int) and conn_id > 0:
|
||||
conn = Connection.browse(conn_id)
|
||||
if conn.exists():
|
||||
conn.write(vals)
|
||||
incoming_conn_ids.add(conn_id)
|
||||
continue
|
||||
new_conn = Connection.create(vals)
|
||||
incoming_conn_ids.add(new_conn.id)
|
||||
|
||||
# Delete connections no longer in the graph
|
||||
existing_conns = Connection.search([('flow_id', '=', self.id)])
|
||||
to_delete = existing_conns.filtered(lambda c: c.id not in incoming_conn_ids)
|
||||
to_delete.unlink()
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flow Evaluation Engine
|
||||
# ------------------------------------------------------------------
|
||||
def evaluate(self, assessment_data):
|
||||
"""Walk the flow graph and return option directives.
|
||||
|
||||
Args:
|
||||
assessment_data: dict with keys like equipment_type, seat_width,
|
||||
selected_option_ids, build_type, etc.
|
||||
|
||||
Returns:
|
||||
dict with enabled/disabled/required option IDs, skip steps, messages.
|
||||
"""
|
||||
self.ensure_one()
|
||||
result = {
|
||||
'enabled_option_ids': set(),
|
||||
'disabled_option_ids': set(),
|
||||
'required_option_ids': set(),
|
||||
'skip_steps': set(),
|
||||
'active_nodes': [],
|
||||
'messages': [],
|
||||
}
|
||||
|
||||
# Find start node
|
||||
start_nodes = self.node_ids.filtered(lambda n: n.node_type == 'start')
|
||||
if not start_nodes:
|
||||
return self._finalize_result(result)
|
||||
|
||||
# BFS walk with cycle protection
|
||||
visited = set()
|
||||
queue = list(start_nodes)
|
||||
max_iterations = 200
|
||||
|
||||
iteration = 0
|
||||
while queue and iteration < max_iterations:
|
||||
iteration += 1
|
||||
node = queue.pop(0)
|
||||
if node.id in visited:
|
||||
continue
|
||||
visited.add(node.id)
|
||||
result['active_nodes'].append(node.id)
|
||||
|
||||
next_nodes = self._evaluate_node(node, assessment_data, result)
|
||||
queue.extend(next_nodes)
|
||||
|
||||
return self._finalize_result(result)
|
||||
|
||||
def _finalize_result(self, result):
|
||||
"""Convert sets to sorted lists for JSON serialization."""
|
||||
for key in ('enabled_option_ids', 'disabled_option_ids',
|
||||
'required_option_ids', 'skip_steps'):
|
||||
result[key] = sorted(result[key])
|
||||
return result
|
||||
|
||||
def _evaluate_node(self, node, data, result):
|
||||
"""Evaluate a single node. Returns list of next nodes to visit."""
|
||||
if node.node_type == 'start':
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
elif node.node_type == 'end':
|
||||
return []
|
||||
|
||||
elif node.node_type == 'decision':
|
||||
passed = self._evaluate_decision(node, data)
|
||||
return self._get_next_nodes(node, 'true' if passed else 'false')
|
||||
|
||||
elif node.node_type == 'measurement_check':
|
||||
passed = self._evaluate_measurement(node, data)
|
||||
return self._get_next_nodes(node, 'pass' if passed else 'fail')
|
||||
|
||||
elif node.node_type == 'option_group':
|
||||
selected = set(data.get('selected_option_ids', []))
|
||||
for opt in node.node_option_ids:
|
||||
if opt.section_option_id and opt.section_option_id.id in selected:
|
||||
result['enabled_option_ids'].update(opt.enables_option_ids.ids)
|
||||
result['disabled_option_ids'].update(opt.disables_option_ids.ids)
|
||||
result['required_option_ids'].update(opt.requires_option_ids.ids)
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
elif node.node_type == 'action':
|
||||
if node.action_type == 'enable':
|
||||
result['enabled_option_ids'].update(node.target_option_ids.ids)
|
||||
elif node.action_type == 'disable':
|
||||
result['disabled_option_ids'].update(node.target_option_ids.ids)
|
||||
elif node.action_type == 'require':
|
||||
result['required_option_ids'].update(node.target_option_ids.ids)
|
||||
elif node.action_type == 'skip_step' and node.target_step:
|
||||
result['skip_steps'].add(node.target_step)
|
||||
elif node.action_type == 'set_value':
|
||||
msg = json.loads(node.config_json or '{}').get('message', '')
|
||||
if msg:
|
||||
result['messages'].append(msg)
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
elif node.node_type == 'product_select':
|
||||
return self._get_next_nodes(node, 'out')
|
||||
|
||||
return []
|
||||
|
||||
def _evaluate_decision(self, node, data):
|
||||
"""Evaluate a decision node condition against assessment data."""
|
||||
field = node.decision_field
|
||||
op = node.decision_operator
|
||||
expected = node.decision_value or ''
|
||||
|
||||
actual = data.get(field, '')
|
||||
if isinstance(actual, (int, float)) and expected:
|
||||
try:
|
||||
expected_num = float(expected)
|
||||
actual_num = float(actual)
|
||||
if op == 'eq':
|
||||
return actual_num == expected_num
|
||||
elif op == 'neq':
|
||||
return actual_num != expected_num
|
||||
elif op == 'gt':
|
||||
return actual_num > expected_num
|
||||
elif op == 'gte':
|
||||
return actual_num >= expected_num
|
||||
elif op == 'lt':
|
||||
return actual_num < expected_num
|
||||
elif op == 'lte':
|
||||
return actual_num <= expected_num
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# String comparison
|
||||
actual_str = str(actual)
|
||||
if op == 'eq':
|
||||
return actual_str == expected
|
||||
elif op == 'neq':
|
||||
return actual_str != expected
|
||||
elif op == 'in':
|
||||
return actual_str in [v.strip() for v in expected.split(',')]
|
||||
return False
|
||||
|
||||
def _evaluate_measurement(self, node, data):
|
||||
"""Evaluate a measurement check node."""
|
||||
field = node.measurement_field
|
||||
if not field:
|
||||
return False
|
||||
actual = data.get(field, 0)
|
||||
try:
|
||||
actual = float(actual)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
threshold = node.threshold_value
|
||||
comp = node.comparison
|
||||
if comp == 'gt':
|
||||
return actual > threshold
|
||||
elif comp == 'gte':
|
||||
return actual >= threshold
|
||||
elif comp == 'lt':
|
||||
return actual < threshold
|
||||
elif comp == 'eq':
|
||||
return abs(actual - threshold) < 0.001
|
||||
elif comp == 'neq':
|
||||
return abs(actual - threshold) >= 0.001
|
||||
return False
|
||||
|
||||
def _get_next_nodes(self, node, port):
|
||||
"""Get target nodes for outgoing connections from a specific port."""
|
||||
connections = node.outgoing_connection_ids.filtered(
|
||||
lambda c: c.source_port == port
|
||||
)
|
||||
return connections.mapped('target_node_id')
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairConfigFlowConnection(models.Model):
|
||||
_name = 'fusion.wc.config.flow.connection'
|
||||
_description = 'Configuration Flow Connection'
|
||||
_order = 'sequence, id'
|
||||
|
||||
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
source_node_id = fields.Many2one('fusion.wc.config.flow.node',
|
||||
string='Source Node', required=True, ondelete='cascade', index=True)
|
||||
target_node_id = fields.Many2one('fusion.wc.config.flow.node',
|
||||
string='Target Node', required=True, ondelete='cascade', index=True)
|
||||
|
||||
source_port = fields.Char(string='Source Port', default='out',
|
||||
help='Port key: out, true, false, or option port_key')
|
||||
|
||||
label = fields.Char(string='Label',
|
||||
help='Text shown on the connection line')
|
||||
|
||||
condition_json = fields.Text(string='Condition', default='{}',
|
||||
help='Optional condition as JSON for advanced routing')
|
||||
|
||||
sequence = fields.Integer(default=10)
|
||||
127
Work in Progress/fusion_quotations/models/wc_config_flow_node.py
Normal file
127
Work in Progress/fusion_quotations/models/wc_config_flow_node.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairConfigFlowNode(models.Model):
|
||||
_name = 'fusion.wc.config.flow.node'
|
||||
_description = 'Configuration Flow Node'
|
||||
_order = 'sequence, id'
|
||||
|
||||
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
name = fields.Char(string='Name', required=True, default='New Node')
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
node_type = fields.Selection([
|
||||
('start', 'Start'),
|
||||
('end', 'End'),
|
||||
('decision', 'Decision (If/Then)'),
|
||||
('option_group', 'Option Group'),
|
||||
('product_select', 'Product Selection'),
|
||||
('measurement_check', 'Measurement Check'),
|
||||
('action', 'Action (Enable/Disable/Require)'),
|
||||
], string='Type', required=True, default='action')
|
||||
|
||||
# Canvas position
|
||||
pos_x = fields.Float(string='X Position', default=100.0)
|
||||
pos_y = fields.Float(string='Y Position', default=100.0)
|
||||
|
||||
# Visual
|
||||
color = fields.Char(string='Color', default='#3b82f6')
|
||||
icon = fields.Char(string='Icon', default='fa-circle')
|
||||
|
||||
# Extensible config for type-specific settings
|
||||
config_json = fields.Text(string='Configuration', default='{}',
|
||||
help='Type-specific configuration as JSON')
|
||||
|
||||
# Link to existing section
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section',
|
||||
help='Link this node to a wheelchair section')
|
||||
|
||||
# Node options (for option_group / product_select)
|
||||
node_option_ids = fields.One2many('fusion.wc.config.flow.node.option',
|
||||
'node_id', string='Options')
|
||||
|
||||
# ── Decision node fields ──
|
||||
decision_field = fields.Selection([
|
||||
('equipment_type', 'Equipment Type'),
|
||||
('wheelchair_type', 'Wheelchair Category'),
|
||||
('powerchair_type', 'Power Chair Category'),
|
||||
('build_type', 'Build Type'),
|
||||
('client_type', 'Client Type'),
|
||||
('reason_for_application', 'Reason for Application'),
|
||||
('seat_width', 'Seat Width (inches)'),
|
||||
('seat_depth', 'Seat Depth (inches)'),
|
||||
('client_weight', 'Client Weight (lbs)'),
|
||||
('back_height', 'Back Height (inches)'),
|
||||
('seat_to_floor', 'Seat to Floor Height (inches)'),
|
||||
('leg_rest_length', 'Leg Rest Length (inches)'),
|
||||
('custom', 'Custom Expression'),
|
||||
('custom_field', 'Custom Field (from equipment data)'),
|
||||
], string='Decision Field')
|
||||
|
||||
custom_field_name = fields.Char(string='Custom Field Name',
|
||||
help='JSON key to check in equipment_data_json (for custom_field decisions)')
|
||||
|
||||
decision_operator = fields.Selection([
|
||||
('eq', '='),
|
||||
('neq', '!='),
|
||||
('gt', '>'),
|
||||
('gte', '>='),
|
||||
('lt', '<'),
|
||||
('lte', '<='),
|
||||
('in', 'In List'),
|
||||
], string='Operator')
|
||||
|
||||
decision_value = fields.Char(string='Expected Value',
|
||||
help='For "In List" use comma-separated values')
|
||||
|
||||
# ── Measurement check fields (reuses upcharge rule pattern) ──
|
||||
measurement_field = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('back_height', 'Back Height'),
|
||||
('seat_to_floor', 'Seat to Floor Height'),
|
||||
('leg_rest_length', 'Leg Rest Length'),
|
||||
('client_weight', 'Client Weight'),
|
||||
], string='Measurement')
|
||||
|
||||
comparison = fields.Selection([
|
||||
('gt', 'Greater Than'),
|
||||
('gte', 'Greater Than or Equal'),
|
||||
('lt', 'Less Than'),
|
||||
('eq', 'Equal To'),
|
||||
('neq', 'Not Equal To'),
|
||||
], string='Comparison')
|
||||
|
||||
threshold_value = fields.Float(string='Threshold')
|
||||
|
||||
# ── Action node fields ──
|
||||
action_type = fields.Selection([
|
||||
('enable', 'Enable Options'),
|
||||
('disable', 'Disable Options'),
|
||||
('require', 'Require Options'),
|
||||
('skip_step', 'Skip Portal Step'),
|
||||
('set_value', 'Set Field Value'),
|
||||
], string='Action Type')
|
||||
|
||||
target_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_target_option_rel',
|
||||
'node_id', 'option_id',
|
||||
string='Target Options',
|
||||
help='Section options affected by this action')
|
||||
|
||||
target_step = fields.Integer(string='Target Step',
|
||||
help='Portal form step number to skip (for skip_step action)')
|
||||
|
||||
# ── Connections (computed for convenience) ──
|
||||
outgoing_connection_ids = fields.One2many(
|
||||
'fusion.wc.config.flow.connection', 'source_node_id',
|
||||
string='Outgoing Connections')
|
||||
incoming_connection_ids = fields.One2many(
|
||||
'fusion.wc.config.flow.connection', 'target_node_id',
|
||||
string='Incoming Connections')
|
||||
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairConfigFlowNodeOption(models.Model):
|
||||
_name = 'fusion.wc.config.flow.node.option'
|
||||
_description = 'Configuration Flow Node Option'
|
||||
_order = 'sequence, id'
|
||||
|
||||
node_id = fields.Many2one('fusion.wc.config.flow.node', string='Node',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
# Link to actual section product option
|
||||
section_option_id = fields.Many2one('fusion.wc.section.option',
|
||||
string='Section Option',
|
||||
help='Link this choice to a wheelchair section product option')
|
||||
|
||||
# Effects when this option is selected
|
||||
enables_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_opt_enables_rel',
|
||||
'node_option_id', 'option_id',
|
||||
string='Enables Options',
|
||||
help='Section options to enable when this choice is selected')
|
||||
|
||||
disables_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_opt_disables_rel',
|
||||
'node_option_id', 'option_id',
|
||||
string='Disables Options',
|
||||
help='Section options to disable when this choice is selected')
|
||||
|
||||
requires_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_flow_node_opt_requires_rel',
|
||||
'node_option_id', 'option_id',
|
||||
string='Requires Options',
|
||||
help='Section options that become required when this choice is selected')
|
||||
|
||||
# Port key for connections — auto-generated from sequence
|
||||
port_key = fields.Char(string='Port Key', compute='_compute_port_key',
|
||||
store=True)
|
||||
|
||||
@api.depends('sequence', 'node_id')
|
||||
def _compute_port_key(self):
|
||||
for record in self:
|
||||
record.port_key = 'opt_%d' % (record.sequence or 0)
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ConfigFlowStep(models.Model):
|
||||
_name = 'fusion.wc.config.flow.step'
|
||||
_description = 'Configuration Flow Form Step'
|
||||
_order = 'sequence, id'
|
||||
|
||||
flow_id = fields.Many2one('fusion.wc.config.flow', string='Flow',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
name = fields.Char(string='Step Name', required=True,
|
||||
help='Display name shown in the step indicator, e.g. "Client Info", "Measurements"')
|
||||
|
||||
step_type = fields.Selection([
|
||||
('client_info', 'Client Information'),
|
||||
('measurements', 'Measurements'),
|
||||
('product_select', 'Product Selection'),
|
||||
('options', 'Options & Accessories'),
|
||||
('review', 'Review & Submit'),
|
||||
('custom', 'Custom Fields'),
|
||||
], string='Step Type', required=True, default='custom')
|
||||
|
||||
icon = fields.Char(string='Icon', default='fa-circle',
|
||||
help='FontAwesome icon class for the step indicator')
|
||||
|
||||
# For product_select / options steps — which section to filter by
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section',
|
||||
help='Link to a wheelchair/equipment section for product filtering')
|
||||
section_code = fields.Char(string='Section Code',
|
||||
help='Alternative to section_id — match section by code. '
|
||||
'Supports comma-separated codes for options steps.')
|
||||
|
||||
# For measurements / custom steps — JSON field definitions
|
||||
fields_json = fields.Text(string='Field Definitions (JSON)',
|
||||
help='JSON array of field definitions for dynamic rendering. '
|
||||
'Each entry: {"name": "field_name", "label": "Display Label", '
|
||||
'"type": "float|integer|selection|char|text|boolean", '
|
||||
'"unit": "inches", "required": true, '
|
||||
'"options": [["value", "Label"], ...]}')
|
||||
|
||||
help_text = fields.Text(string='Help Text',
|
||||
help='Instructions displayed at the top of this step')
|
||||
is_required = fields.Boolean(string='Required', default=True,
|
||||
help='If true, this step must be completed before proceeding')
|
||||
454
Work in Progress/fusion_quotations/models/wc_section.py
Normal file
454
Work in Progress/fusion_quotations/models/wc_section.py
Normal file
@@ -0,0 +1,454 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# =========================================================================
|
||||
# ADP device_type -> section code mapping
|
||||
# Maps each device_type string from fusion.adp.device.code to the
|
||||
# fusion.wc.section code where matched products should be placed.
|
||||
# =========================================================================
|
||||
DEVICE_TYPE_SECTION_MAP = {
|
||||
# =================================================================
|
||||
# MANUAL WHEELCHAIR FRAMES (Section 2b base devices)
|
||||
# =================================================================
|
||||
'Adult Standard Manual Type 1 Wheelchair': 'mw_frame',
|
||||
'Adult Lightweight Standard Type 1 Wheelchair': 'mw_frame',
|
||||
'Adult Lightweight Performance Type 3 Wheelchair': 'mw_frame',
|
||||
'Adult Lightweight Performance Manual Wheelchair': 'mw_frame',
|
||||
'Adult High Performance Rigid Type 4 Wheelchair': 'mw_frame',
|
||||
'Adult High Performance Rigid Manual Wheelchair': 'mw_frame',
|
||||
'Adult Manual Dynamic Tilt Type 5 Wheelchair': 'mw_frame',
|
||||
'Adult Manual Dynamic Tilt Wheelchair': 'mw_frame',
|
||||
'Standard Manual Wheelchair Frame with Manual Dynamic Tilt': 'mw_frame',
|
||||
'Paediatric Lightweight Standard Type 1 Wheelchair': 'mw_frame',
|
||||
'Paediatric Lightweight Performance Type 4 Wheelchair': 'mw_frame',
|
||||
'Paediatric High Performance Rigid Type 4 Wheelchair': 'mw_frame',
|
||||
'Paediatric High Performance Rigid Manual Wheelchair': 'mw_frame',
|
||||
'Paediatric Manual Dynamic Tilt Type 5 Wheelchair': 'mw_frame',
|
||||
'Paediatric Specific Specialty Stroller': 'mw_frame',
|
||||
|
||||
# =================================================================
|
||||
# WALKER / ROLLATOR / AMBULATION AIDS FRAMES (Section 2a)
|
||||
# =================================================================
|
||||
'Adult Wheeled Walker Type 1': 'walker_frame',
|
||||
'Adult Wheeled Walker Type 2': 'walker_frame',
|
||||
'Adult Wheeled Walker Type 3': 'walker_frame',
|
||||
'Paediatric Wheeled Walker Type 1': 'walker_frame',
|
||||
'Paediatric Wheeled Walker Type 2': 'walker_frame',
|
||||
'Paediatric Walking Frame': 'walker_frame',
|
||||
'Paediatric Specific Walking Frame': 'walker_frame',
|
||||
'Paediatric Standing Frame Type 1': 'walker_frame',
|
||||
'Paediatric Standing Frame Type 2': 'walker_frame',
|
||||
'Forearm Crutches': 'walker_frame',
|
||||
'Walker Addons': 'walker_accessories',
|
||||
|
||||
# Walker ADP options (adolescent size upgrades)
|
||||
'AA - Custom Modifications': 'walker_adp_options',
|
||||
|
||||
# =================================================================
|
||||
# POWER BASE / SCOOTER FRAMES (Section 2c)
|
||||
# =================================================================
|
||||
'Adult Power Base Type 1': 'pw_frame',
|
||||
'Adult Power Base Type 2': 'pw_frame',
|
||||
'Adult Power Base Type 3': 'pw_frame',
|
||||
'Paediatric Power Base Type 2': 'pw_frame',
|
||||
'Paediatric Power Base Type 3': 'pw_frame',
|
||||
'Power Scooter': 'pw_frame',
|
||||
|
||||
# Power base ADP funded options
|
||||
'MW - Adjustable Tension Back Upholstery up to 18" Frame Width': 'pw_adp_options',
|
||||
'MW - Adjustable Tension Back Upholstery over 18" Frame Width': 'pw_adp_options',
|
||||
'PW - Adjustable Tension Back Upholstery up to 18" Frame Width': 'pw_adp_options',
|
||||
'PW - Adjustable Tension Back Upholstery over 18" Frame Width': 'pw_adp_options',
|
||||
'Midline Control': 'pw_adp_options',
|
||||
'Manual Recline Option': 'pw_adp_options',
|
||||
'Recliner Option': 'pw_adp_options',
|
||||
'MW - Angle Adjustable Footplates (pair)': 'pw_adp_options',
|
||||
'PW - Angle Adjustable Footplates (pair)': 'pw_adp_options',
|
||||
'Manual Elevating Legrests (pair)': 'pw_adp_options',
|
||||
'Elevating Legrests (pair)': 'pw_adp_options',
|
||||
'Swingaway Mounting Bracket': 'pw_adp_options',
|
||||
'One Piece 90/90 Front Riggings': 'pw_adp_options',
|
||||
'Seat Package 1 for Power Bases': 'pw_adp_options',
|
||||
'Seat Package 2 for Power Bases': 'pw_adp_options',
|
||||
'PW - Oxygen Tank': 'pw_adp_options',
|
||||
'MW - Oxygen Tank Holder': 'pw_adp_options',
|
||||
'PW - Ventilator Tray': 'pw_adp_options',
|
||||
'MW - Ventilator Tray': 'pw_adp_options',
|
||||
|
||||
# Power specialty controls (* require clinical rationale)
|
||||
'Specialty Controls 1 Non Standard Joystick*': 'pw_specialty_controls',
|
||||
'Specialty Controls 2 Chin/Rim Control*': 'pw_specialty_controls',
|
||||
'Specialty Controls 3 Simple Touch*': 'pw_specialty_controls',
|
||||
'Specialty Controls 4 Proximity Control*': 'pw_specialty_controls',
|
||||
'Specialty Controls 5 Breath Control*': 'pw_specialty_controls',
|
||||
'Specialty Controls 6 Scanners*': 'pw_specialty_controls',
|
||||
'Auto Correction System*': 'pw_specialty_controls',
|
||||
|
||||
# Power positioning devices (require Justification for Funding Chart)
|
||||
'Power Tilt Only': 'pw_positioning',
|
||||
'Power Recline Only': 'pw_positioning',
|
||||
'Power Tilt and Recline': 'pw_positioning',
|
||||
'Power Elevating Footrests': 'pw_positioning',
|
||||
'Multi-function Control Box': 'pw_positioning',
|
||||
'Power Add-On Device': 'pw_positioning',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – SEAT CUSHION (shared across wheelchair types)
|
||||
# =================================================================
|
||||
'Seat Cushion': 'seat_cushion',
|
||||
'Seat Cushion Cover(s)': 'seat_cushion_cover',
|
||||
'Seat Options': 'seat_options',
|
||||
'Seat Hardware': 'seat_hardware',
|
||||
'Pommel/Adductors': 'pommel',
|
||||
'Pommel Hardware': 'pommel_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – BACK SUPPORT
|
||||
# =================================================================
|
||||
'Back Support': 'back_support',
|
||||
'Back Support Options': 'back_support_options',
|
||||
'Back Cover': 'back_cover',
|
||||
'Back Hardware': 'back_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – COMPLETE ASSEMBLY
|
||||
# =================================================================
|
||||
'Complete Assembly': 'complete_assembly',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – HEADREST / NECKREST
|
||||
# =================================================================
|
||||
'Headrest/Neckrest': 'headrest',
|
||||
'Headrest/Neckrest Options': 'headrest_options',
|
||||
'Headrest/Neckrest Hardware': 'headrest_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – POSITIONING BELTS
|
||||
# =================================================================
|
||||
'Positioning Belts': 'positioning_belts',
|
||||
'Positioning Belts Options': 'positioning_belt_options',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – ARM SUPPORT
|
||||
# =================================================================
|
||||
'Arm Support(s)': 'arm_support',
|
||||
'Arm Support Options': 'arm_support_options',
|
||||
'Arm Support Hardware': 'arm_support_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – TRAY
|
||||
# =================================================================
|
||||
'Tray': 'tray',
|
||||
'Tray Options': 'tray_options',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – LATERAL SUPPORT
|
||||
# =================================================================
|
||||
'Lateral Support(s)': 'lateral_support',
|
||||
'Lateral Support Options': 'lateral_support_options',
|
||||
'Lateral Support Hardware': 'lateral_support_hardware',
|
||||
|
||||
# =================================================================
|
||||
# SEATING – FOOT / LEG SUPPORT
|
||||
# =================================================================
|
||||
'Foot/Leg Support(s)': 'foot_leg_support',
|
||||
'Foot/Leg Support Options': 'foot_leg_support_options',
|
||||
'Foot/Leg Support Hardware': 'foot_leg_support_hardware',
|
||||
|
||||
# =================================================================
|
||||
# MANUAL WHEELCHAIR ACCESSORIES (Section 2b options/extras)
|
||||
# =================================================================
|
||||
'Amputee Axle Plates (pair)': 'mw_accessories',
|
||||
'Caster Pin Locks (pair)': 'mw_accessories',
|
||||
'Clothing Guards': 'mw_accessories',
|
||||
'Grade Aids (pair)': 'mw_accessories',
|
||||
'Heavy Duty Cross Braces & Upholstery': 'mw_accessories',
|
||||
'One Arm/Lever Drive': 'mw_accessories',
|
||||
'Plastic Coated Handrims': 'mw_accessories',
|
||||
'Projected Handrims (pair)': 'mw_accessories',
|
||||
'Quick Release Axles (pair)': 'mw_accessories',
|
||||
'Spoke Protectors (pair)': 'mw_accessories',
|
||||
'Stroller Handles/Paediatric': 'mw_accessories',
|
||||
'Titanium Frame *': 'mw_accessories',
|
||||
'Uni-Lateral Wheel Lock': 'mw_accessories',
|
||||
'Unilateral Hand Brake': 'mw_accessories',
|
||||
|
||||
# =================================================================
|
||||
# MW ADP UPCHARGE / MODIFICATION CODES
|
||||
# =================================================================
|
||||
'MW - Seat Width Required is Greater Than 18"': 'mw_adp_options',
|
||||
'MW - Seat Depth Required is Greater Than 18"': 'mw_adp_options',
|
||||
'MW - Heavy Duty Model, Client Weight Exceeds 250 Lbs': 'mw_adp_options',
|
||||
'MW - Heavy Duty Model, Client Weight Exceeds 350 Lbs': 'mw_adp_options',
|
||||
'MW - Heavy Duty Model, Client Weight Exceeds 400 Lbs': 'mw_adp_options',
|
||||
'MW - Custom Modifications': 'mw_adp_options',
|
||||
|
||||
# =================================================================
|
||||
# PW ADP UPCHARGE / MODIFICATION CODES
|
||||
# =================================================================
|
||||
'PW - Seat Width Required is Greater Than 18"': 'pw_adp_options',
|
||||
'PW - Seat Depth Required is Greater Than 18"': 'pw_adp_options',
|
||||
'PW - Heavy Duty Model, Client Weight Exceeds 250 Lbs': 'pw_adp_options',
|
||||
'PW - Heavy Duty Model, Client Weight Exceeds 350 Lbs': 'pw_adp_options',
|
||||
'PW - Heavy Duty Model, Client Weight Exceeds 400 Lbs': 'pw_adp_options',
|
||||
'PW - Custom Modifications': 'pw_adp_options',
|
||||
'SE - Custom Modifications': 'pw_adp_options',
|
||||
}
|
||||
|
||||
|
||||
class WheelchairSection(models.Model):
|
||||
_name = 'fusion.wc.section'
|
||||
_description = 'Wheelchair Configuration Section'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(string='Code', required=True, index=True,
|
||||
help='Unique identifier e.g. frame, cushion, backrest')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', default='both', required=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
result = [(t.code, t.name) for t in types]
|
||||
# Group options for shared sections
|
||||
result.append(('wheelchair', 'All Wheelchairs (Manual + Power)'))
|
||||
result.append(('both', 'All Equipment Types'))
|
||||
return result
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair'),
|
||||
('walker', 'Walker / Ambulation Aid'),
|
||||
('wheelchair', 'All Wheelchairs (Manual + Power)'),
|
||||
('both', 'All Equipment Types'),
|
||||
]
|
||||
|
||||
icon = fields.Char(string='Icon', help='FontAwesome icon class e.g. fa-wheelchair')
|
||||
description = fields.Text(string='Description',
|
||||
help='Help text shown to sales reps during assessment')
|
||||
|
||||
# Section behavior flags
|
||||
is_adp_options_section = fields.Boolean(string='ADP Options Section',
|
||||
help='If true, this section shows as a checkbox grid of ADP funded options')
|
||||
has_build_type = fields.Boolean(string='Has Build Type',
|
||||
help='If true, shows Modular / Custom Fabricated toggle for items in this section')
|
||||
allow_multiple = fields.Boolean(string='Allow Multiple', default=True,
|
||||
help='Can select multiple products in this section')
|
||||
required = fields.Boolean(string='Required', default=False,
|
||||
help='Must have at least one selection')
|
||||
|
||||
# Measurement fields configuration
|
||||
has_width = fields.Boolean(string='Collect Width')
|
||||
has_depth = fields.Boolean(string='Collect Depth')
|
||||
has_height = fields.Boolean(string='Collect Height')
|
||||
has_length = fields.Boolean(string='Collect Length')
|
||||
|
||||
width_label = fields.Char(string='Width Label', default='Width (inches)')
|
||||
depth_label = fields.Char(string='Depth Label', default='Depth (inches)')
|
||||
height_label = fields.Char(string='Height Label', default='Height (inches)')
|
||||
length_label = fields.Char(string='Length Label', default='Length (inches)')
|
||||
|
||||
# Product filter for custom search
|
||||
product_category_id = fields.Many2one('product.category',
|
||||
string='Product Category Filter',
|
||||
help='Default category to filter products when searching in this section')
|
||||
|
||||
# Hierarchy
|
||||
parent_id = fields.Many2one('fusion.wc.section', string='Parent Section',
|
||||
ondelete='cascade', index=True)
|
||||
child_ids = fields.One2many('fusion.wc.section', 'parent_id',
|
||||
string='Sub-Sections')
|
||||
|
||||
# Options
|
||||
option_ids = fields.One2many('fusion.wc.section.option', 'section_id',
|
||||
string='Product Options')
|
||||
option_count = fields.Integer(string='Options', compute='_compute_option_count')
|
||||
|
||||
@api.depends('option_ids')
|
||||
def _compute_option_count(self):
|
||||
for record in self:
|
||||
record.option_count = len(record.option_ids)
|
||||
|
||||
@api.depends('name', 'code')
|
||||
def _compute_display_name(self):
|
||||
for record in self:
|
||||
if record.parent_id:
|
||||
record.display_name = f"{record.parent_id.name} / {record.name}"
|
||||
else:
|
||||
record.display_name = record.name or ''
|
||||
|
||||
# =====================================================================
|
||||
# AUTO-POPULATE OPTIONS FROM INVENTORY
|
||||
# =====================================================================
|
||||
def action_auto_populate_options(self):
|
||||
"""Auto-populate product options for THIS section by matching
|
||||
products in inventory whose ADP device code maps to this section
|
||||
via the ADP device type lookup.
|
||||
|
||||
Called from a button on the section form view.
|
||||
"""
|
||||
self.ensure_one()
|
||||
stats = self._populate_section_from_products()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Auto-Populate Complete'),
|
||||
'message': _(
|
||||
'Section "%(section)s": %(added)d products added, '
|
||||
'%(skipped)d already existed, %(unmapped)d unmapped.',
|
||||
section=self.name,
|
||||
added=stats['added'],
|
||||
skipped=stats['skipped'],
|
||||
unmapped=stats['unmapped'],
|
||||
),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def action_auto_populate_all_sections(self):
|
||||
"""Auto-populate ALL sections at once. Called from a menu action."""
|
||||
sections = self.search([])
|
||||
totals = {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
section_results = []
|
||||
|
||||
for section in sections:
|
||||
stats = section._populate_section_from_products()
|
||||
totals['added'] += stats['added']
|
||||
totals['skipped'] += stats['skipped']
|
||||
totals['unmapped'] += stats['unmapped']
|
||||
if stats['added'] > 0:
|
||||
section_results.append(f"{section.name}: +{stats['added']}")
|
||||
|
||||
details = ', '.join(section_results) if section_results else 'No new products found'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Auto-Populate All Sections Complete'),
|
||||
'message': _(
|
||||
'Total: %(added)d products added across all sections. '
|
||||
'%(skipped)d already existed. '
|
||||
'%(unmapped)d products had unmapped device types.\n'
|
||||
'%(details)s',
|
||||
added=totals['added'],
|
||||
skipped=totals['skipped'],
|
||||
unmapped=totals['unmapped'],
|
||||
details=details,
|
||||
),
|
||||
'type': 'success',
|
||||
'sticky': True,
|
||||
},
|
||||
}
|
||||
|
||||
def _populate_section_from_products(self):
|
||||
"""Find products whose ADP device type maps to this section,
|
||||
and create one fusion.wc.section.option per product *template*.
|
||||
|
||||
Variants (size, colour, etc.) live under the template and are
|
||||
selected when the sales rep adds the item to an assessment.
|
||||
This keeps the option list compact and manageable.
|
||||
|
||||
Returns dict with counts: {added, skipped, unmapped}
|
||||
"""
|
||||
self.ensure_one()
|
||||
SectionOption = self.env['fusion.wc.section.option'].sudo()
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
ProductTemplate = self.env['product.template'].sudo()
|
||||
|
||||
# Build reverse map: which device_types map to THIS section's code
|
||||
my_device_types = [
|
||||
dtype for dtype, section_code in DEVICE_TYPE_SECTION_MAP.items()
|
||||
if section_code == self.code
|
||||
]
|
||||
|
||||
if not my_device_types:
|
||||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
|
||||
# Find ADP device codes with matching device_type
|
||||
matching_adp_codes = ADPDevice.search([
|
||||
('device_type', 'in', my_device_types),
|
||||
('active', '=', True),
|
||||
])
|
||||
adp_code_strings = matching_adp_codes.mapped('device_code')
|
||||
|
||||
if not adp_code_strings:
|
||||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
|
||||
# Find product templates with those ADP codes
|
||||
products = ProductTemplate.search([
|
||||
('x_fc_adp_device_code', 'in', adp_code_strings),
|
||||
])
|
||||
|
||||
if not products:
|
||||
return {'added': 0, 'skipped': 0, 'unmapped': 0}
|
||||
|
||||
# Get existing option template IDs for this section (avoid duplicates)
|
||||
existing_tmpl_ids = set(
|
||||
SectionOption.search([
|
||||
('section_id', '=', self.id),
|
||||
]).mapped('product_tmpl_id.id')
|
||||
)
|
||||
|
||||
# Pre-fetch build types: ADP code -> build_type
|
||||
build_type_cache = {}
|
||||
for adp_code_str in set(products.mapped('x_fc_adp_device_code')):
|
||||
adp_device = ADPDevice.search([
|
||||
('device_code', '=', adp_code_str),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
build_type = 'both'
|
||||
if adp_device and adp_device.build_type:
|
||||
if adp_device.build_type == 'modular':
|
||||
build_type = 'modular'
|
||||
elif adp_device.build_type == 'custom_fabricated':
|
||||
build_type = 'custom_fabricated'
|
||||
build_type_cache[adp_code_str] = build_type
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
unmapped = 0
|
||||
|
||||
# Batch-create one option record per product template
|
||||
vals_list = []
|
||||
for tmpl in products:
|
||||
if tmpl.id in existing_tmpl_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
adp_code = tmpl.x_fc_adp_device_code
|
||||
build_type = build_type_cache.get(adp_code, 'both')
|
||||
|
||||
vals_list.append({
|
||||
'section_id': self.id,
|
||||
'product_tmpl_id': tmpl.id,
|
||||
'is_standard': True,
|
||||
'available_build_types': build_type,
|
||||
'sequence': 10 + added,
|
||||
})
|
||||
added += 1
|
||||
existing_tmpl_ids.add(tmpl.id)
|
||||
|
||||
# Bulk create for performance
|
||||
if vals_list:
|
||||
SectionOption.create(vals_list)
|
||||
|
||||
_logger.info(
|
||||
'Section "%s" auto-populate: %d product templates added, '
|
||||
'%d already existed, %d unmapped',
|
||||
self.name, added, skipped, unmapped,
|
||||
)
|
||||
return {'added': added, 'skipped': skipped, 'unmapped': unmapped}
|
||||
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairSectionOption(models.Model):
|
||||
_name = 'fusion.wc.section.option'
|
||||
_description = 'Wheelchair Section Product Option'
|
||||
_order = 'is_standard desc, sequence, id'
|
||||
|
||||
section_id = fields.Many2one('fusion.wc.section', string='Section',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
|
||||
# ── Parent product (template level) ──
|
||||
product_tmpl_id = fields.Many2one('product.template',
|
||||
string='Product', required=True, index=True,
|
||||
help='The parent product. Variants (size, colour) are chosen '
|
||||
'when the sales rep adds this item to an assessment.')
|
||||
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
is_standard = fields.Boolean(string='Standard Option', default=False,
|
||||
help='Standard options are shown prominently; non-standard found via search')
|
||||
|
||||
# ── Variant count (computed) ──
|
||||
variant_count = fields.Integer(string='Variants',
|
||||
compute='_compute_variant_count')
|
||||
|
||||
# ── ADP info (from template) ──
|
||||
adp_device_code = fields.Char(string='ADP Code',
|
||||
related='product_tmpl_id.x_fc_adp_device_code', readonly=True)
|
||||
adp_price = fields.Float(string='ADP Price',
|
||||
related='product_tmpl_id.x_fc_adp_price', readonly=True)
|
||||
list_price = fields.Float(string='Sale Price',
|
||||
related='product_tmpl_id.list_price', readonly=True)
|
||||
|
||||
# Build type applicability (for seating sections)
|
||||
available_build_types = fields.Selection([
|
||||
('modular', 'Modular Only'),
|
||||
('custom_fabricated', 'Custom Fabricated Only'),
|
||||
('both', 'Both'),
|
||||
], string='Build Types', default='both',
|
||||
help='Which build types this option is available for')
|
||||
|
||||
requires_clinical_rationale = fields.Boolean(string='Requires Clinical Rationale',
|
||||
help='Sales rep must provide clinical rationale when selecting this option')
|
||||
|
||||
# Compatibility rules
|
||||
incompatible_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_option_incompatible_rel',
|
||||
'option_id', 'incompatible_option_id',
|
||||
string='Incompatible With',
|
||||
help='Cannot be selected together with these options')
|
||||
requires_option_ids = fields.Many2many(
|
||||
'fusion.wc.section.option',
|
||||
'wc_option_requires_rel',
|
||||
'option_id', 'required_option_id',
|
||||
string='Requires Options',
|
||||
help='These options must be selected first')
|
||||
|
||||
@api.depends('product_tmpl_id')
|
||||
def _compute_variant_count(self):
|
||||
for record in self:
|
||||
if record.product_tmpl_id:
|
||||
record.variant_count = record.product_tmpl_id.product_variant_count
|
||||
else:
|
||||
record.variant_count = 0
|
||||
|
||||
@api.depends('product_tmpl_id', 'section_id')
|
||||
def _compute_display_name(self):
|
||||
for record in self:
|
||||
parts = []
|
||||
if record.section_id:
|
||||
parts.append(record.section_id.name)
|
||||
if record.product_tmpl_id:
|
||||
parts.append(record.product_tmpl_id.display_name)
|
||||
record.display_name = ' / '.join(parts) if parts else ''
|
||||
109
Work in Progress/fusion_quotations/models/wc_upcharge_rule.py
Normal file
109
Work in Progress/fusion_quotations/models/wc_upcharge_rule.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class WheelchairUpchargeRule(models.Model):
|
||||
_name = 'fusion.wc.upcharge.rule'
|
||||
_description = 'Wheelchair Upcharge Rule'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True,
|
||||
help='e.g. "Width > 18 inches (WAMA)"')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
|
||||
# Trigger configuration
|
||||
trigger_type = fields.Selection([
|
||||
('measurement', 'Measurement Threshold'),
|
||||
('weight', 'Client Weight'),
|
||||
('dimension_mismatch', 'Dimension Mismatch'),
|
||||
], string='Trigger Type', required=True)
|
||||
|
||||
# For measurement triggers
|
||||
measurement_field = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('back_height', 'Back Height'),
|
||||
('seat_to_floor', 'Seat to Floor Height'),
|
||||
('leg_rest_length', 'Leg Rest Length'),
|
||||
], string='Measurement Field')
|
||||
comparison = fields.Selection([
|
||||
('gt', 'Greater Than'),
|
||||
('gte', 'Greater Than or Equal'),
|
||||
('lt', 'Less Than'),
|
||||
('eq', 'Equal To'),
|
||||
('neq', 'Not Equal To'),
|
||||
], string='Comparison', default='gt')
|
||||
threshold_value = fields.Float(string='Threshold Value',
|
||||
help='e.g. 18.0 for WAMA width threshold')
|
||||
|
||||
# For weight triggers
|
||||
weight_min = fields.Float(string='Min Weight (lbs)',
|
||||
help='Trigger when client weight exceeds this value')
|
||||
weight_max = fields.Float(string='Max Weight (lbs)',
|
||||
help='Upper bound (0 or empty = no upper limit)')
|
||||
|
||||
# For dimension mismatch
|
||||
compare_field_1 = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
], string='Compare Field 1')
|
||||
compare_field_2 = fields.Selection([
|
||||
('seat_width', 'Seat Width'),
|
||||
('back_width', 'Backrest Width'),
|
||||
('seat_depth', 'Seat Depth'),
|
||||
], string='Compare Field 2')
|
||||
|
||||
# What to add when triggered
|
||||
adp_device_code = fields.Char(string='ADP Device Code', required=True,
|
||||
help='ADP code to add (e.g. WAMA, WAMB, WAMF)')
|
||||
product_id = fields.Many2one('product.product', string='Product to Add',
|
||||
help='If set, this product will be added to the quotation. '
|
||||
'If empty, a generic line is created from the ADP code.')
|
||||
|
||||
# Equipment type applicability
|
||||
equipment_type = fields.Selection(
|
||||
selection='_get_equipment_type_selection',
|
||||
string='Equipment Type', default='both', required=True)
|
||||
|
||||
@api.model
|
||||
def _get_equipment_type_selection(self):
|
||||
types = self.env['fusion.equipment.type'].sudo().search([], order='sequence')
|
||||
if types:
|
||||
result = [(t.code, t.name) for t in types]
|
||||
result.append(('both', 'Both (All Types)'))
|
||||
return result
|
||||
return [
|
||||
('manual_wheelchair', 'Manual Wheelchair'),
|
||||
('power_wheelchair', 'Power Wheelchair'),
|
||||
('both', 'Both'),
|
||||
]
|
||||
|
||||
# Mutual exclusion
|
||||
mutually_exclusive_group = fields.Char(string='Exclusive Group',
|
||||
help='Rules in the same group are mutually exclusive '
|
||||
'(only the highest-priority matching rule applies). '
|
||||
'e.g. "weight" for WAMF/WAMG/WAMH')
|
||||
|
||||
description = fields.Text(string='Description',
|
||||
help='Explanation shown to the sales rep when this rule triggers')
|
||||
|
||||
# Linked ADP device code record (for price lookup)
|
||||
adp_device_code_id = fields.Many2one('fusion.adp.device.code',
|
||||
string='ADP Device Code Record',
|
||||
compute='_compute_adp_device_code_id', store=True)
|
||||
|
||||
@api.depends('adp_device_code')
|
||||
def _compute_adp_device_code_id(self):
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
for record in self:
|
||||
if record.adp_device_code:
|
||||
record.adp_device_code_id = ADPDevice.search(
|
||||
[('device_code', '=', record.adp_device_code), ('active', '=', True)],
|
||||
limit=1
|
||||
)
|
||||
else:
|
||||
record.adp_device_code_id = False
|
||||
@@ -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
|
||||
|
37
Work in Progress/fusion_quotations/security/security.xml
Normal file
37
Work in Progress/fusion_quotations/security/security.xml
Normal 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>
|
||||
BIN
Work in Progress/fusion_quotations/static/description/icon.png
Normal file
BIN
Work in Progress/fusion_quotations/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,760 @@
|
||||
/* =================================================================
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Compact option cards (seating tabs) ── */
|
||||
.wc-option-compact {
|
||||
padding: 0.45rem 0.6rem !important;
|
||||
border-radius: 0.375rem !important;
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.wc-option-compact:hover {
|
||||
border-color: #adb5bd !important;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.wc-option-compact .form-check-input:checked ~ .form-check-label {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.wc-option-card.wc-option-compact .form-check-input:checked {
|
||||
/* parent card highlight when checked */
|
||||
}
|
||||
|
||||
.wc-options-grid .col-md-4 {
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.wc-option-compact .form-check {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.wc-option-compact .form-check-input {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.wc-option-compact .wc-opt-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wc-option-compact .wc-opt-meta {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
margin-left: 1.35em;
|
||||
margin-top: 0.15rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wc-option-compact .wc-opt-adp {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Checked card highlight */
|
||||
.wc-option-compact:has(.form-check-input:checked) {
|
||||
border-color: #198754 !important;
|
||||
background: #f0faf4;
|
||||
}
|
||||
|
||||
/* ── Collapsible section groups (accordion within tab) ── */
|
||||
.wc-section-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wc-section-group {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wc-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: none;
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.wc-group-header:hover {
|
||||
background: #e2e2e2;
|
||||
}
|
||||
|
||||
.wc-section-group.open > .wc-group-header {
|
||||
background: #2c3e50;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wc-section-group.open > .wc-group-header:hover {
|
||||
background: #34495e;
|
||||
}
|
||||
|
||||
.wc-group-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wc-group-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wc-group-count {
|
||||
display: none;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
color: #2c3e50;
|
||||
border-radius: 999px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
padding: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wc-section-group.has-selection > .wc-group-header .wc-group-count {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* When closed + has selections: green accent */
|
||||
.wc-section-group.has-selection:not(.open) > .wc-group-header {
|
||||
background: #d1e7dd;
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
.wc-section-group.has-selection:not(.open) > .wc-group-header .wc-group-count {
|
||||
background: #198754;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wc-group-chevron {
|
||||
font-size: 0.7rem;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wc-section-group.open > .wc-group-header .wc-group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.wc-group-body {
|
||||
display: none;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.wc-section-group.open > .wc-group-body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Real-time filter ── */
|
||||
.wc-option-filter-wrap {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.wc-option-filter-wrap .input-group-text {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
color: var(--bs-secondary-color);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.wc-option-filter-wrap .wc-option-filter {
|
||||
border-left: none;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.wc-option-filter-wrap .wc-option-filter:focus {
|
||||
box-shadow: none;
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.wc-option-filter-wrap .wc-option-filter:focus + .input-group-text,
|
||||
.wc-option-filter-wrap .input-group:focus-within .input-group-text {
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Seating Side Tabs (responsive vertical tabs)
|
||||
Desktop: side nav left + content right
|
||||
Mobile: horizontal scrollable tabs on top + content below
|
||||
----------------------------------------------------------------- */
|
||||
|
||||
/* ── Container ── */
|
||||
.wc-seating-tabs {
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.wc-seating-tabs > .row {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* ── Tab Navigation Column ── */
|
||||
.wc-tab-nav-col {
|
||||
background: var(--bs-tertiary-bg, #f8f9fa);
|
||||
border-right: 1px solid var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
.wc-tab-nav {
|
||||
padding: 0.5rem 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ── Individual Tab Button ── */
|
||||
.wc-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wc-tab-btn:hover {
|
||||
background: rgba(var(--bs-primary-rgb), 0.06);
|
||||
}
|
||||
|
||||
.wc-tab-btn.active {
|
||||
background: var(--bs-body-bg, #fff);
|
||||
color: var(--bs-primary);
|
||||
font-weight: 600;
|
||||
border-left-color: var(--bs-primary);
|
||||
box-shadow: 1px 0 0 var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.wc-tab-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wc-tab-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Badge showing selected items count — hidden when 0 */
|
||||
.wc-tab-badge {
|
||||
display: none;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
background: var(--bs-primary);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
padding: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wc-tab-badge.has-items {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Tab Content Column ── */
|
||||
.wc-tab-content-col {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.wc-tab-content {
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Tab Panels ── */
|
||||
.wc-tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wc-tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wc-tab-panel-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.625rem;
|
||||
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* ── MOBILE: horizontal scrollable tabs ── */
|
||||
@media (max-width: 767.98px) {
|
||||
.wc-seating-tabs > .row {
|
||||
min-height: auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wc-tab-nav-col {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wc-tab-nav {
|
||||
flex-direction: row !important;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wc-tab-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wc-tab-btn {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
min-width: 72px;
|
||||
border-left: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wc-tab-btn.active {
|
||||
border-left-color: transparent;
|
||||
border-bottom-color: var(--bs-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wc-tab-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.wc-tab-content-col {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.wc-tab-content {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
1331
Work in Progress/fusion_quotations/static/src/js/quotation_form.js
Normal file
1331
Work in Progress/fusion_quotations/static/src/js/quotation_form.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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'">></option>
|
||||
<option value="gte" t-att-selected="selectedNode.decision_operator === 'gte'">≥</option>
|
||||
<option value="lt" t-att-selected="selectedNode.decision_operator === '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>
|
||||
@@ -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>
|
||||
62
Work in Progress/fusion_quotations/views/menus.xml
Normal file
62
Work in Progress/fusion_quotations/views/menus.xml
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Quotation Builder under Configuration -->
|
||||
<menuitem id="menu_wc_quotation_root"
|
||||
name="Quotation Builder"
|
||||
parent="fusion_claims.menu_adp_config"
|
||||
sequence="50"/>
|
||||
|
||||
<!-- 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>
|
||||
File diff suppressed because it is too large
Load Diff
496
Work in Progress/fusion_quotations/views/wc_assessment_views.xml
Normal file
496
Work in Progress/fusion_quotations/views/wc_assessment_views.xml
Normal 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 & 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;"><iframe src="<field name="public_url" readonly="1"/>/embed"
|
||||
width="100%" height="900"
|
||||
frameborder="0"
|
||||
allow="clipboard-write"></iframe></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>
|
||||
@@ -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>
|
||||
146
Work in Progress/fusion_quotations/views/wc_section_views.xml
Normal file
146
Work in Progress/fusion_quotations/views/wc_section_views.xml
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user