feat(portal): fix configurator 500, hide manual measurements, upgrade job card
1. Configurator step 2/3 500 fix: fp.coating.config was retired
(Sub-11) but the controller still queried it -> KeyError. Swapped
to fusion.plating.process.type (the real coating taxonomy on entech:
Hard Chrome, EN Low Phos, Type I Anodize, etc). Step 2 template
dropped dead refs (coat.process_type_id / spec_reference / thickness_*
/ certification_level), now shows code + process_family + description.
Pricing helper relaxed: filters out rules keyed to the dead model
and silently returns {'available': False} -> template shows 'Quote
will be priced by EN Plating' instead of fake numbers.
2. Configurator step 1: manual measurements hidden per customer
feedback. Length/Width/Height/Surface Area are kept as hidden 0s so
the rest of the flow doesn't error; backend trimesh still auto-calcs
surface area silently when STL is uploaded. Single file input split
into two: separate Drawing (PDF) + 3D Model (STL/STP/STEP/IGES)
uploads so customer can send both. Multi-upload session shape:
attachment_ids list. Submit handler re-keys ALL uploads onto the
new quote_request.
3. Job card upgraded: new fp_portal_job_card macro shared by dashboard
+ jobs list. Renders wrap div containing main anchor (whole card
clickable -> detail page) + sibling actions footer (4 doc download
quick-buttons: SO / WO / CoC / Packing + Repeat Order form).
Forms-inside-anchor is invalid HTML so the footer lives as a
sibling, not a child. Card now shows part name+number and ship-to
address pulled inline from job.x_fc_job_id.sale_order_id chain.
Same data also added to detail-page hero for consistency.
Version bump: 19.0.3.6.0 -> 19.0.3.7.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user