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
and wrappers. When rendered as an anchor - // the whole card becomes a click target (jobs list + dashboard). + &:hover { + box-shadow: $fp-shadow-card-hover; + border-color: $fp-aqua; + transform: translateY(-1px); + } +} + +.o_fp_job_card_main { display: block; color: inherit; text-decoration: none; - transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease; &:hover, &:focus-visible { color: inherit; text-decoration: none; - box-shadow: $fp-shadow-card-hover; - border-color: $fp-aqua; - transform: translateY(-1px); } &:focus-visible { outline: 2px solid $fp-teal; outline-offset: 2px; } +} - .o_fp_job_header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: $fp-space-3; +.o_fp_job_header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: .55rem; + gap: .65rem; - .o_fp_job_ref { - font-weight: 600; - color: $fp-text; - font-size: .98rem; - } - .o_fp_job_meta { - color: $fp-muted; - font-size: .8rem; - margin-left: .65rem; - } + .o_fp_job_ref { + font-weight: 600; + color: $fp-text; + font-size: .98rem; } - - .o_fp_job_docs { - display: flex; - flex-wrap: wrap; - gap: .35rem; - margin-top: $fp-space-3; - padding-top: .6rem; - border-top: 1px solid $fp-section-bg; + .o_fp_job_meta { + color: $fp-muted; + font-size: .8rem; + margin-left: .55rem; } } +// Part name / number row under the header +.o_fp_job_part { + color: $fp-text-body; + font-size: .78rem; + margin-bottom: .2rem; + .o_fp_job_part_icon { color: $fp-muted-light; margin-right: .3rem; } +} +// Shipping address row +.o_fp_job_ship { + color: $fp-muted; + font-size: .76rem; + margin-bottom: $fp-space-3; + .o_fp_job_ship_icon { color: $fp-muted-light; margin-right: .3rem; } +} + +// Actions footer — siblings of the main anchor. Doc download chips on +// the left, Repeat Order on the right. Border-top visually separates. +.o_fp_job_card_actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: $fp-space-3; + margin-top: $fp-space-3; + padding-top: .7rem; + border-top: 1px solid $fp-section-bg; + flex-wrap: wrap; +} +.o_fp_job_card_docs { + display: flex; + align-items: center; + gap: .4rem; + flex-wrap: wrap; +} +// Compact icon-only download chip. Tooltip via `title` attr. +.o_fp_doc_quick_btn { + display: inline-flex; + align-items: center; + gap: .25rem; + padding: .3rem .55rem; + background: $fp-section-bg; + color: $fp-teal; + border: 1px solid $fp-card-border; + border-radius: $fp-radius-chip; + font-size: .72rem; + font-weight: 500; + text-decoration: none; + transition: background .12s ease, color .12s ease; + &:hover { + background: $fp-mint; + color: $fp-teal-dark; + text-decoration: none; + } + &.o_fp_doc_quick_btn_pending { + background: $fp-card-bg; + color: $fp-muted-light; + border: 1px dashed $fp-card-border-dark; + cursor: not-allowed; + pointer-events: none; + } +} +// Legacy: kept for any place still rendering chips below the stepper. +.o_fp_job_docs { + display: flex; + flex-wrap: wrap; + gap: .35rem; + margin-top: $fp-space-3; + padding-top: .6rem; + border-top: 1px solid $fp-section-bg; +} + .o_fp_secondary_panels { display: grid; // Auto-fit so 5 panels arrange nicely as 3+2 / 2+2+1 / 1 column at diff --git a/fusion_plating/fusion_plating_portal/views/fp_portal_configurator_templates.xml b/fusion_plating/fusion_plating_portal/views/fp_portal_configurator_templates.xml index 6ad324ac..bab008ca 100644 --- a/fusion_plating/fusion_plating_portal/views/fp_portal_configurator_templates.xml +++ b/fusion_plating/fusion_plating_portal/views/fp_portal_configurator_templates.xml @@ -156,67 +156,49 @@
- -
- -
- -

- 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 +

+ +
-
- - -
- Manual Measurements - - (if no 3D model uploaded) - -
- - - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
+ + + + + +
@@ -295,26 +277,13 @@
-

+

+ + +

+

- -

-

- - -

-

- - - - - - -

-

- - - +

@@ -408,9 +377,9 @@

-
-
Spec
-
+
+
Code
+
Quantity
diff --git a/fusion_plating/fusion_plating_portal/views/fp_portal_dashboard.xml b/fusion_plating/fusion_plating_portal/views/fp_portal_dashboard.xml index a437541c..dfee6d16 100644 --- a/fusion_plating/fusion_plating_portal/views/fp_portal_dashboard.xml +++ b/fusion_plating/fusion_plating_portal/views/fp_portal_dashboard.xml @@ -67,49 +67,9 @@ -
-
-
- - - units - · ETA - -
- - - - -
- - - - - - - - - -
- - 📑 CoC - - - 📑 CoC · pending - - - 📦 - -
-
+ + + diff --git a/fusion_plating/fusion_plating_portal/views/fp_portal_macros.xml b/fusion_plating/fusion_plating_portal/views/fp_portal_macros.xml index 872cba17..3106ad15 100644 --- a/fusion_plating/fusion_plating_portal/views/fp_portal_macros.xml +++ b/fusion_plating/fusion_plating_portal/views/fp_portal_macros.xml @@ -78,6 +78,129 @@ + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml b/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml index 88a68b70..e371d641 100644 --- a/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml +++ b/fusion_plating/fusion_plating_portal/views/fp_portal_templates.xml @@ -436,34 +436,9 @@ @@ -477,15 +452,28 @@
- + + + + + +
Work Order

-
- -
+ +
+ · + +
+
+ +
+ +
+
Qty @@ -503,6 +491,12 @@ Tracking
+
+ Ship to + + · + +