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:
gsinghpal
2026-05-17 12:34:06 -04:00
parent 153b980e2b
commit 2802fcf738
7 changed files with 393 additions and 256 deletions

View File

@@ -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),