537 lines
24 KiB
Python
537 lines
24 KiB
Python
# -*- 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')
|