diff --git a/fusion_plating/fusion_plating_portal/__manifest__.py b/fusion_plating/fusion_plating_portal/__manifest__.py index 01e0288d..375b896a 100644 --- a/fusion_plating/fusion_plating_portal/__manifest__.py +++ b/fusion_plating/fusion_plating_portal/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Customer Portal', - 'version': '19.0.3.6.0', + 'version': '19.0.3.7.0', 'category': 'Manufacturing/Plating', 'summary': 'Customer-facing portal for plating shops: online RFQ, job status, ' 'CoC downloads, invoice access.', diff --git a/fusion_plating/fusion_plating_portal/controllers/portal_configurator.py b/fusion_plating/fusion_plating_portal/controllers/portal_configurator.py index 3a601ff5..99a8023b 100644 --- a/fusion_plating/fusion_plating_portal/controllers/portal_configurator.py +++ b/fusion_plating/fusion_plating_portal/controllers/portal_configurator.py @@ -53,42 +53,58 @@ class FpPortalConfigurator(CustomerPortal): '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'), + 'geometry_source': kw.get('geometry_source', 'upload'), + # Manual measurements are HIDDEN in the template per customer + # feedback 2026-05-17 — kept as hidden 0s so the rest of the + # flow doesn't error on missing keys. Backend computes them + # if/when needed. '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), + # Multi-upload: customer may submit drawing + 3D + others. + # Collect IDs in a list so they all flow through to the RFQ. + 'attachment_ids': [], + 'attachment_names': [], } - # 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).') + def _save_upload(file_upload, label): + """Persist a single uploaded file and append to session lists. + Returns the attachment record or None.""" + if not (file_upload and hasattr(file_upload, 'read')): + return None + file_data = file_upload.read() + if not file_data: + return None + 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_ids'].append(attachment.id) + session_data['attachment_names'].append( + '%s (%s)' % (file_upload.filename, label), + ) + # STL surface-area auto-calc (silent — backend value only, + # not surfaced in UI per the hidden-measurements decision). + fname = file_upload.filename.lower() + if fname.endswith('.stl'): + try: + import io + import trimesh + mesh = trimesh.load(io.BytesIO(file_data), file_type='stl') + # mm^2 -> 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('STL surface-area auto-calc skipped (trimesh missing or bad mesh).') + return attachment + + _save_upload(kw.get('part_drawing'), 'drawing') + _save_upload(kw.get('part_3d_model'), '3D model') + # Back-compat: legacy single-file input still accepted. + _save_upload(kw.get('part_file'), 'attachment') request.session['fp_configurator'] = session_data return request.redirect('/my/configurator/coating') @@ -128,8 +144,11 @@ class FpPortalConfigurator(CustomerPortal): request.session['fp_configurator'] = session_data return request.redirect('/my/configurator/estimate') - coatings = request.env['fp.coating.config'].sudo().search( - [('active', '=', True)], order='sequence', + # fp.coating.config retired post-Sub-11. Use process.type as the + # customer-facing coating picker — its records (Hard Chrome, EN + # Low-Phos, etc.) are exactly what a customer would select from. + coatings = request.env['fusion.plating.process.type'].sudo().search( + [('active', '=', True)], order='sequence, name', ) return request.render('fusion_plating_portal.portal_configurator_step2', { 'page_name': 'fp_configurator', @@ -147,14 +166,20 @@ class FpPortalConfigurator(CustomerPortal): 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( + coating = request.env['fusion.plating.process.type'].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) + # Estimate price from pricing rules. Best-effort: returns + # {'available': False} when rules don't match or process.type + # lacks the data the rules expect (post-coating-retire transition). + try: + estimated_price = self._estimate_price(session_data, coating) + except Exception: + _logger.info('Skipping price estimate — pricing helper unavailable.', exc_info=True) + estimated_price = {'min': 0, 'max': 0, 'available': False} return request.render('fusion_plating_portal.portal_configurator_step3', { 'page_name': 'fp_configurator', @@ -177,7 +202,7 @@ class FpPortalConfigurator(CustomerPortal): return request.redirect('/my/configurator/new') partner = request.env.user.partner_id - coating = request.env['fp.coating.config'].sudo().browse( + coating = request.env['fusion.plating.process.type'].sudo().browse( session_data['coating_config_id'], ) @@ -213,22 +238,29 @@ class FpPortalConfigurator(CustomerPortal): '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)] + # Link the selected process type (coating IS the process type now + # that fp.coating.config is retired). + if coating.exists(): + vals['process_type_ids'] = [(4, coating.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) + # Re-key uploaded attachments onto the new quote request so they + # appear on its chatter. Multi-upload — customer may have sent + # both a drawing and a 3D model (or more). + att_ids = session_data.get('attachment_ids') or [] + if not att_ids and session_data.get('attachment_id'): + # Back-compat for old session shape (single attachment_id key). + att_ids = [session_data['attachment_id']] + for att_id in att_ids: + attachment = request.env['ir.attachment'].sudo().browse(att_id) if attachment.exists(): attachment.write({ 'res_model': 'fusion.plating.quote.request', 'res_id': quote.id, }) - quote.drawing_attachment_ids = [(4, attachment.id)] + if 'drawing_attachment_ids' in quote._fields: + quote.drawing_attachment_ids = [(4, attachment.id)] # Clear session request.session.pop('fp_configurator', None) @@ -242,40 +274,37 @@ class FpPortalConfigurator(CustomerPortal): # Pricing helper # ====================================================================== def _estimate_price(self, session_data, coating): - """Calculate estimated price range from pricing rules. + """Best-effort price estimate. Returns {'min', 'max', 'available'}. - 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. + Post-coating-retire (Sub-11) the rule schema still references + fp.coating.config; ``coating`` here is now a process.type record. + That means rules with a ``coating_config_id`` set won't match + and we silently fall through to {'available': False} — which + the template renders as 'Quote will be priced by EN Plating'. + Estimate is non-essential; final price comes from EN's review. """ - rules = request.env['fp.pricing.rule'].sudo().search( - [('active', '=', True)], order='sequence', - ) + Rule = request.env.get('fp.pricing.rule') + if Rule is None: + return {'min': 0, 'max': 0, 'available': False} + rules = 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: + # Skip any rule keyed to coating_config — model is gone. + if 'coating_config_id' in rule._fields and rule.coating_config_id: + continue + if getattr(rule, 'substrate_material', None): 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 @@ -283,7 +312,6 @@ class FpPortalConfigurator(CustomerPortal): 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': @@ -293,17 +321,10 @@ class FpPortalConfigurator(CustomerPortal): 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 + base_total = unit * qty + (best.setup_fee or 0) 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), diff --git a/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss b/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss index cbb7e319..61fcc77c 100644 --- a/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss +++ b/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss @@ -91,61 +91,131 @@ } } +// Job card: outer wrap is a plain div so we can place an interactive +// actions footer (Repeat Order form, doc download links) as a SIBLING +// of the main anchor — forms inside anchors are invalid HTML and +// browser-buggy. Hover/lift effect lives on the wrap; click target is +// the inner .o_fp_job_card_main anchor only. .o_fp_job_card { @extend .o_fp_card; padding: $fp-space-4; border-radius: $fp-radius-tile; margin-bottom: $fp-space-3; box-shadow: $fp-shadow-card; + transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease; - // Works for both
- -- Drag and drop your file here, or click to browse -
-- Accepted: STL, STP, STEP, IGES, PDF (max 50 MB) -
- + +PDF drawing
++ 2D / dimensioned drawing +
+ +STL / STP / STEP / IGES
++ Optional — speeds up estimation +
+ ++
+
+
-
-
-
-
-
-
-