diff --git a/fusion-plating/fusion_plating_portal/__manifest__.py b/fusion-plating/fusion_plating_portal/__manifest__.py index f47c37b0..635fd475 100644 --- a/fusion-plating/fusion_plating_portal/__manifest__.py +++ b/fusion-plating/fusion_plating_portal/__manifest__.py @@ -43,6 +43,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'currency': 'CAD', 'depends': [ 'fusion_plating', + 'fusion_plating_configurator', 'portal', 'website', 'mail', @@ -57,6 +58,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_quote_request_views.xml', 'views/fp_portal_dashboard.xml', 'views/fp_portal_templates.xml', + 'views/fp_portal_configurator_templates.xml', 'views/fp_portal_breadcrumbs.xml', 'views/fp_menu.xml', ], diff --git a/fusion-plating/fusion_plating_portal/controllers/__init__.py b/fusion-plating/fusion_plating_portal/controllers/__init__.py index a66f4a2a..d91cdae7 100644 --- a/fusion-plating/fusion_plating_portal/controllers/__init__.py +++ b/fusion-plating/fusion_plating_portal/controllers/__init__.py @@ -4,3 +4,4 @@ # Part of the Fusion Plating product family. from . import portal +from . import portal_configurator diff --git a/fusion-plating/fusion_plating_portal/controllers/portal_configurator.py b/fusion-plating/fusion_plating_portal/controllers/portal_configurator.py new file mode 100644 index 00000000..3a601ff5 --- /dev/null +++ b/fusion-plating/fusion_plating_portal/controllers/portal_configurator.py @@ -0,0 +1,311 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +import base64 +import logging + +from odoo import _, http +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal + +_logger = logging.getLogger(__name__) + + +class FpPortalConfigurator(CustomerPortal): + """Self-service configurator wizard on the customer portal. + + Three-step flow: + 1. Upload part (3D / PDF) or enter manual measurements + 2. Select a coating configuration + 3. View estimated price range and submit quote request + """ + + # ====================================================================== + # Landing — start new or view past requests + # ====================================================================== + @http.route('/my/configurator', type='http', auth='user', website=True) + def portal_configurator_landing(self, **kw): + """Landing page -- start new quote or view past requests.""" + partner = request.env.user.partner_id + quotes = request.env['fusion.plating.quote.request'].sudo().search( + [('partner_id', 'child_of', partner.commercial_partner_id.id)], + order='create_date desc', limit=10, + ) + return request.render('fusion_plating_portal.portal_configurator_landing', { + 'page_name': 'fp_configurator', + 'quotes': quotes, + }) + + # ====================================================================== + # Step 1 — Upload part or enter manual measurements + # ====================================================================== + @http.route( + '/my/configurator/new', type='http', auth='user', website=True, + methods=['GET', 'POST'], csrf=True, + ) + def portal_configurator_step1(self, **kw): + """Step 1: upload part or enter manual measurements.""" + if request.httprequest.method == 'POST': + # Save step 1 data to session + session_data = { + 'part_name': kw.get('part_name', ''), + 'part_number': kw.get('part_number', ''), + 'substrate_material': kw.get('substrate_material', 'steel'), + 'geometry_source': kw.get('geometry_source', 'manual'), + 'surface_area': float(kw.get('surface_area', 0) or 0), + 'dimensions_length': float(kw.get('dimensions_length', 0) or 0), + 'dimensions_width': float(kw.get('dimensions_width', 0) or 0), + 'dimensions_height': float(kw.get('dimensions_height', 0) or 0), + } + # Handle file upload + file_upload = kw.get('part_file') + if file_upload and hasattr(file_upload, 'read'): + file_data = file_upload.read() + if file_data: + attachment = request.env['ir.attachment'].sudo().create({ + 'name': file_upload.filename, + 'datas': base64.b64encode(file_data), + 'res_model': 'fusion.plating.quote.request', + 'type': 'binary', + }) + session_data['attachment_id'] = attachment.id + session_data['attachment_name'] = file_upload.filename + fname = file_upload.filename.lower() + if fname.endswith(('.stl', '.stp', '.step', '.iges', '.igs')): + session_data['geometry_source'] = '3d_model' + else: + session_data['geometry_source'] = 'pdf_drawing' + + # Try to calculate surface area for STL files + if fname.endswith('.stl'): + try: + import io + import trimesh + mesh = trimesh.load(io.BytesIO(file_data), file_type='stl') + # Convert mm^2 to sq in (1 sq in = 645.16 mm^2) + session_data['surface_area'] = round(mesh.area / 645.16, 4) + session_data['auto_calculated'] = True + except Exception: + _logger.info('Could not auto-calculate STL surface area (trimesh not available).') + + request.session['fp_configurator'] = session_data + return request.redirect('/my/configurator/coating') + + # GET -- show form + materials = [ + ('aluminium', 'Aluminium'), + ('steel', 'Steel'), + ('stainless', 'Stainless Steel'), + ('copper', 'Copper'), + ('titanium', 'Titanium'), + ('other', 'Other'), + ] + return request.render('fusion_plating_portal.portal_configurator_step1', { + 'page_name': 'fp_configurator', + 'materials': materials, + }) + + # ====================================================================== + # Step 2 — Select coating configuration + # ====================================================================== + @http.route( + '/my/configurator/coating', type='http', auth='user', website=True, + methods=['GET', 'POST'], csrf=True, + ) + def portal_configurator_step2(self, **kw): + """Step 2: select coating configuration.""" + session_data = request.session.get('fp_configurator', {}) + if not session_data: + return request.redirect('/my/configurator/new') + + if request.httprequest.method == 'POST': + coating_id = int(kw.get('coating_config_id', 0)) + quantity = int(kw.get('quantity', 1) or 1) + session_data['coating_config_id'] = coating_id + session_data['quantity'] = quantity + request.session['fp_configurator'] = session_data + return request.redirect('/my/configurator/estimate') + + coatings = request.env['fp.coating.config'].sudo().search( + [('active', '=', True)], order='sequence', + ) + return request.render('fusion_plating_portal.portal_configurator_step2', { + 'page_name': 'fp_configurator', + 'coatings': coatings, + 'session_data': session_data, + }) + + # ====================================================================== + # Step 3 — Estimate & submit + # ====================================================================== + @http.route('/my/configurator/estimate', type='http', auth='user', website=True) + def portal_configurator_step3(self, **kw): + """Step 3: show estimated price and submit.""" + session_data = request.session.get('fp_configurator', {}) + if not session_data or not session_data.get('coating_config_id'): + return request.redirect('/my/configurator/new') + + coating = request.env['fp.coating.config'].sudo().browse( + session_data['coating_config_id'], + ) + if not coating.exists(): + return request.redirect('/my/configurator/coating') + + # Calculate estimated price from pricing rules + estimated_price = self._estimate_price(session_data, coating) + + return request.render('fusion_plating_portal.portal_configurator_step3', { + 'page_name': 'fp_configurator', + 'session_data': session_data, + 'coating': coating, + 'estimated_price': estimated_price, + }) + + # ====================================================================== + # Submit — create quote request + # ====================================================================== + @http.route( + '/my/configurator/submit', type='http', auth='user', website=True, + methods=['POST'], csrf=True, + ) + def portal_configurator_submit(self, **kw): + """Submit quote request from configurator.""" + session_data = request.session.get('fp_configurator', {}) + if not session_data or not session_data.get('coating_config_id'): + return request.redirect('/my/configurator/new') + + partner = request.env.user.partner_id + coating = request.env['fp.coating.config'].sudo().browse( + session_data['coating_config_id'], + ) + + # Build part description HTML + part_desc = '

%s

' % ( + session_data.get('part_name', '') or 'Unnamed Part', + ) + if session_data.get('part_number'): + part_desc += '

Part Number: %s

' % session_data['part_number'] + part_desc += '

Material: %s

' % session_data.get('substrate_material', '') + if session_data.get('surface_area'): + part_desc += '

Surface Area: %s sq in

' % session_data['surface_area'] + dims = [] + for dim_key, dim_label in [ + ('dimensions_length', 'L'), ('dimensions_width', 'W'), ('dimensions_height', 'H'), + ]: + val = session_data.get(dim_key, 0) + if val: + dims.append('%s: %s in' % (dim_label, val)) + if dims: + part_desc += '

Dimensions: %s

' % ', '.join(dims) + if coating.exists(): + part_desc += '

Coating: %s

' % coating.name + + vals = { + 'partner_id': partner.id, + 'contact_name': partner.name, + 'contact_email': partner.email, + 'contact_phone': partner.phone or '', + 'company_name': partner.parent_id.name if partner.parent_id else partner.name, + 'part_description': part_desc, + 'quantity': session_data.get('quantity', 1), + 'special_instructions': kw.get('special_instructions', ''), + } + + # Link coating process type + if coating.exists() and coating.process_type_id: + vals['process_type_ids'] = [(4, coating.process_type_id.id)] + + quote = request.env['fusion.plating.quote.request'].sudo().create(vals) + + # Attach uploaded file to the quote request + attachment_id = session_data.get('attachment_id') + if attachment_id: + attachment = request.env['ir.attachment'].sudo().browse(attachment_id) + if attachment.exists(): + attachment.write({ + 'res_model': 'fusion.plating.quote.request', + 'res_id': quote.id, + }) + quote.drawing_attachment_ids = [(4, attachment.id)] + + # Clear session + request.session.pop('fp_configurator', None) + + return request.render('fusion_plating_portal.portal_configurator_success', { + 'page_name': 'fp_configurator', + 'quote': quote, + }) + + # ====================================================================== + # Pricing helper + # ====================================================================== + def _estimate_price(self, session_data, coating): + """Calculate estimated price range from pricing rules. + + Returns a dict with ``min``, ``max``, and ``available`` keys. + The range is deliberately wide (+/- 15-25%) because final quotes + account for masking complexity, rack configuration, etc. + """ + rules = request.env['fp.pricing.rule'].sudo().search( + [('active', '=', True)], order='sequence', + ) + area = float(session_data.get('surface_area', 0)) + qty = int(session_data.get('quantity', 1)) + substrate = session_data.get('substrate_material', '') + cert_level = coating.certification_level if coating else 'commercial' + + if not area or not rules: + return {'min': 0, 'max': 0, 'available': False} + + # Find best matching rule (same scoring as fp.quote.configurator) + best = None + best_score = -1 + for rule in rules: + score = 0 + if rule.coating_config_id: + if rule.coating_config_id.id != coating.id: + continue + score += 4 + if rule.substrate_material: + if rule.substrate_material != substrate: + continue + score += 2 + if rule.certification_level: + if rule.certification_level != cert_level: + continue + score += 1 + if score > best_score: + best_score = score + best = rule + + if not best: + return {'min': 0, 'max': 0, 'available': False} + + # Calculate base price + if best.pricing_method == 'per_sqin': + unit = area * best.base_rate + elif best.pricing_method == 'per_sqft': + unit = (area / 144.0) * best.base_rate + elif best.pricing_method == 'per_piece': + unit = best.base_rate + else: + unit = best.base_rate + + # Apply thickness factor (use min thickness from coating) + thickness = coating.thickness_min or 1.0 + unit *= thickness * best.thickness_factor + + base_total = unit * qty + best.setup_fee + + # Apply minimum charge + if best.minimum_charge and base_total < best.minimum_charge: + base_total = best.minimum_charge + + # Return a range (85% to 125%) to account for complexity, masking, etc. + return { + 'min': round(base_total * 0.85, 2), + 'max': round(base_total * 1.25, 2), + 'available': True, + } diff --git a/fusion-plating/fusion_plating_portal/views/fp_portal_breadcrumbs.xml b/fusion-plating/fusion_plating_portal/views/fp_portal_breadcrumbs.xml index 293e96af..6c2d7284 100644 --- a/fusion-plating/fusion_plating_portal/views/fp_portal_breadcrumbs.xml +++ b/fusion-plating/fusion_plating_portal/views/fp_portal_breadcrumbs.xml @@ -22,6 +22,13 @@ Dashboard + + +