changes
This commit is contained in:
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