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