# -*- 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//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//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//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//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//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//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//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}