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

@@ -156,67 +156,49 @@
</select>
</div>
<!-- File Upload -->
<div class="mb-4">
<label class="form-label">Part Drawing or 3D Model</label>
<div class="o_fp_file_drop_zone p-4">
<i class="fa fa-cloud-upload"/>
<p class="mb-1 fw-semibold">
Drag and drop your file here, or click to browse
</p>
<p class="small text-muted mb-2">
Accepted: STL, STP, STEP, IGES, PDF (max 50 MB)
</p>
<input type="file" name="part_file" id="part_file"
class="form-control"
accept=".stl,.stp,.step,.iges,.igs,.pdf"/>
<!-- File Uploads — separate drawing + 3D model.
Customer can upload either or both. STL gets
trimesh surface-area auto-calc server-side
(not shown to customer — backend uses it for
future pricing). -->
<div class="row">
<div class="col-md-6 mb-4">
<label class="form-label">Drawing (PDF)</label>
<div class="o_fp_file_drop_zone p-3">
<i class="fa fa-file-pdf-o"/>
<p class="mb-1 fw-semibold">PDF drawing</p>
<p class="small text-muted mb-2">
2D / dimensioned drawing
</p>
<input type="file" name="part_drawing" id="part_drawing"
class="form-control"
accept=".pdf"/>
</div>
</div>
<div class="col-md-6 mb-4">
<label class="form-label">3D Model</label>
<div class="o_fp_file_drop_zone p-3">
<i class="fa fa-cube"/>
<p class="mb-1 fw-semibold">STL / STP / STEP / IGES</p>
<p class="small text-muted mb-2">
Optional — speeds up estimation
</p>
<input type="file" name="part_3d_model" id="part_3d_model"
class="form-control"
accept=".stl,.stp,.step,.iges,.igs"/>
</div>
</div>
</div>
<hr class="my-4"/>
<!-- Manual Measurements -->
<h6 class="mb-3">
<i class="fa fa-ruler-combined me-2"/>Manual Measurements
<span class="text-muted small fw-normal ms-2">
(if no 3D model uploaded)
</span>
</h6>
<input type="hidden" name="geometry_source" value="manual"/>
<div class="row mb-3">
<div class="col-md-4">
<label for="dimensions_length" class="form-label">Length (in)</label>
<input type="number" step="0.001" min="0"
id="dimensions_length" name="dimensions_length"
class="form-control" placeholder="0.000"/>
</div>
<div class="col-md-4">
<label for="dimensions_width" class="form-label">Width (in)</label>
<input type="number" step="0.001" min="0"
id="dimensions_width" name="dimensions_width"
class="form-control" placeholder="0.000"/>
</div>
<div class="col-md-4">
<label for="dimensions_height" class="form-label">Height (in)</label>
<input type="number" step="0.001" min="0"
id="dimensions_height" name="dimensions_height"
class="form-control" placeholder="0.000"/>
</div>
</div>
<div class="mb-4">
<label for="surface_area" class="form-label">
Surface Area (sq in)
<span class="text-muted small fw-normal ms-1">
-- auto-calculated if STL uploaded
</span>
</label>
<input type="number" step="0.0001" min="0"
id="surface_area" name="surface_area"
class="form-control" placeholder="0.0000"/>
</div>
<!-- Manual measurements hidden per customer-feedback 2026-05-17:
backend computes these (or doesn't) — not the
customer's job. Fields kept as hidden inputs at 0
so the controller doesn't error on missing keys. -->
<input type="hidden" name="geometry_source" value="upload"/>
<input type="hidden" name="dimensions_length" value="0"/>
<input type="hidden" name="dimensions_width" value="0"/>
<input type="hidden" name="dimensions_height" value="0"/>
<input type="hidden" name="surface_area" value="0"/>
<!-- Navigation -->
<div class="d-flex justify-content-between">
@@ -295,26 +277,13 @@
<h6 class="card-title mb-2" style="color: var(--bs-body-color);">
<t t-out="coat.name"/>
</h6>
<p t-if="coat.process_type_id" class="small text-muted mb-1">
<p t-if="coat.code" class="small text-muted mb-1">
<i class="fa fa-tag me-1"/>
<t t-out="coat.code"/>
</p>
<p t-if="coat.process_family" class="small text-muted mb-1">
<i class="fa fa-flask me-1"/>
<t t-out="coat.process_type_id.name"/>
</p>
<p t-if="coat.spec_reference" class="small text-muted mb-1">
<i class="fa fa-bookmark me-1"/>
<t t-out="coat.spec_reference"/>
</p>
<p t-if="coat.thickness_min or coat.thickness_max" class="small text-muted mb-1">
<i class="fa fa-arrows-v me-1"/>
<t t-if="coat.thickness_min" t-out="coat.thickness_min"/>
<t t-if="coat.thickness_min and coat.thickness_max"> - </t>
<t t-if="coat.thickness_max" t-out="coat.thickness_max"/>
<t t-out="coat.thickness_uom or 'mils'"/>
</p>
<p t-if="coat.certification_level and coat.certification_level != 'commercial'"
class="small mb-0">
<span class="badge text-bg-warning">
<t t-out="dict(coat._fields['certification_level']._description_selection(coat.env)).get(coat.certification_level)"/>
</span>
<t t-out="dict(coat._fields['process_family']._description_selection(coat.env)).get(coat.process_family)"/>
</p>
<p t-if="coat.description" class="small text-muted mt-2 mb-0"
t-out="coat.description"/>
@@ -408,9 +377,9 @@
<strong t-out="coating.name"/>
</div>
</div>
<div t-if="coating.spec_reference" class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Spec</div>
<div class="col-sm-8" t-out="coating.spec_reference"/>
<div t-if="coating.code" class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Code</div>
<div class="col-sm-8" t-out="coating.code"/>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted small fw-semibold">Quantity</div>

View File

@@ -67,49 +67,9 @@
<t t-if="recent_jobs">
<t t-foreach="recent_jobs[:3]" t-as="job">
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
<div class="o_fp_job_header">
<div>
<span class="o_fp_job_ref" t-out="job.name"/>
<span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
</span>
</div>
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
<!-- State -> active step index mapping.
5 customer-visible stages: Received / Inspected / Plating / QC / Ship.
state_idx >= 5 means all done (shipped or complete). -->
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
<t t-set="steps" t-value="[
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
]"/>
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
<!-- Doc chips: CoC + tracking (V1) -->
<div class="o_fp_job_docs">
<t t-if="job.coc_attachment_id">
<span class="o_fp_doc_chip">📑 CoC</span>
</t>
<t t-else="">
<span class="o_fp_doc_chip o_fp_doc_chip_pending">📑 CoC · pending</span>
</t>
<t t-if="job.tracking_ref">
<span class="o_fp_doc_chip">📦 <span t-out="job.tracking_ref"/></span>
</t>
</div>
</a>
<t t-call="fusion_plating_portal.fp_portal_job_card">
<t t-set="job" t-value="job"/>
</t>
</t>
<t t-if="job_count > 3">

View File

@@ -78,6 +78,129 @@
</t>
</template>
<!-- ================================================================== -->
<!-- Job card — shared between /my/home dashboard and /my/jobs list. -->
<!-- Pass `job` (fusion.plating.portal.job). Renders a wrap div with -->
<!-- inner anchor (whole card click target = detail page) and a sibling -->
<!-- actions footer (doc download chips + Repeat Order form). Forms -->
<!-- live OUTSIDE the anchor because forms-inside-anchors is invalid -->
<!-- HTML and clicks would otherwise double-fire. -->
<!-- ================================================================== -->
<template id="fp_portal_job_card" name="Portal: Job Card">
<!-- Pull related backend records inline (cards are 3-5 per page,
query cost is fine). Each `t-set` is a no-op if the field
chain breaks. -->
<t t-set="backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
<t t-set="so" t-value="backend_job.sale_order_id if backend_job and 'sale_order_id' in backend_job._fields else False"/>
<t t-set="part" t-value="backend_job.part_catalog_id if backend_job and 'part_catalog_id' in backend_job._fields else False"/>
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
<t t-set="picking" t-value="so.picking_ids.filtered(lambda p: p.state == 'done')[:1] if so and 'picking_ids' in so._fields else False"/>
<!-- Stepper state mapping (same as detail page).
received -> idx 0; in_progress -> 2; quality_check -> 3;
ready_to_ship -> 4; shipped / complete -> 5 (all done). -->
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
<t t-set="steps" t-value="[
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
]"/>
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
<div class="o_fp_job_card">
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card_main">
<div class="o_fp_job_header">
<div>
<span class="o_fp_job_ref" t-out="job.name"/>
<span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
</span>
</div>
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
<!-- Part info: prefer the part catalog ref, fall back to
process types listed on the portal job. -->
<t t-if="part">
<div class="o_fp_job_part">
<span class="o_fp_job_part_icon">📦</span>
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
<t t-out="part.name or part.display_name"/>
</div>
</t>
<t t-elif="job.process_type_ids">
<div class="o_fp_job_part">
<span class="o_fp_job_part_icon">📦</span>
<t t-out="', '.join(job.process_type_ids.mapped('name'))"/>
</div>
</t>
<!-- Shipping address: customer may have multiple sites; the
SO carries which one this job ships to. -->
<t t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
<div class="o_fp_job_ship">
<span class="o_fp_job_ship_icon">📍</span>
Ship to: <t t-out="ship_to.name"/>
<t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
</div>
</t>
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
</a>
<!-- Actions footer: doc quick-downloads + Repeat Order. Lives
OUTSIDE the .o_fp_job_card_main anchor so the form button
doesn't double-fire navigation + form submission. -->
<div class="o_fp_job_card_actions">
<div class="o_fp_job_card_docs">
<t t-if="so">
<a t-attf-href="/my/jobs/#{job.id}/so_confirmation"
class="o_fp_doc_quick_btn" title="Sales Order Confirmation">
📄 SO
</a>
</t>
<t t-if="backend_job">
<a t-attf-href="/my/jobs/#{job.id}/wo_detail"
class="o_fp_doc_quick_btn" title="Work Order Detail">
🛠 WO
</a>
</t>
<t t-if="job.coc_attachment_id">
<a t-attf-href="/my/jobs/#{job.id}/coc"
class="o_fp_doc_quick_btn" title="Certificate of Conformance">
📑 CoC
</a>
</t>
<t t-else="">
<span class="o_fp_doc_quick_btn o_fp_doc_quick_btn_pending"
title="Will appear after QC completes">
📑 CoC
</span>
</t>
<t t-if="job.packing_list_attachment_id">
<a t-attf-href="/web/content/#{job.packing_list_attachment_id.id}?download=true"
class="o_fp_doc_quick_btn" title="Packing Slip">
📦 Packing
</a>
</t>
</div>
<form t-attf-action="/my/jobs/#{job.id}/repeat" method="post" class="m-0">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">
<i class="fa fa-repeat"/> Repeat Order
</button>
</form>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- Doc group (detail page) — pass label + docs list of dicts: -->
<!-- {label, sub, url, icon_class, pending} -->

View File

@@ -436,34 +436,9 @@
<t t-if="jobs">
<div class="o_fp_dashboard">
<t t-foreach="jobs" t-as="job">
<a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
<div class="o_fp_job_header">
<div>
<span class="o_fp_job_ref" t-out="job.name"/>
<span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
</span>
</div>
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
<!-- State -> active step index map (same as dashboard) -->
<t t-set="state_to_idx" t-value="{'received': 0, 'in_progress': 2, 'quality_check': 3, 'ready_to_ship': 4, 'shipped': 5, 'complete': 5}"/>
<t t-set="state_idx" t-value="state_to_idx.get(job.state, 0)"/>
<t t-set="steps" t-value="[
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
]"/>
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
</a>
<t t-call="fusion_plating_portal.fp_portal_job_card">
<t t-set="job" t-value="job"/>
</t>
</t>
</div>
</t>
@@ -477,15 +452,28 @@
<t t-call="portal.portal_layout">
<div class="o_fp_job_detail">
<!-- Hero header -->
<!-- Hero header: WO ref + part + ship-to + key facts -->
<t t-set="backend_job" t-value="job.x_fc_job_id if 'x_fc_job_id' in job._fields else False"/>
<t t-set="so" t-value="backend_job.sale_order_id if backend_job and 'sale_order_id' in backend_job._fields else False"/>
<t t-set="part" t-value="backend_job.part_catalog_id if backend_job and 'part_catalog_id' in backend_job._fields else False"/>
<t t-set="ship_to" t-value="so.partner_shipping_id if so else False"/>
<div class="o_fp_job_detail_hero">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="o_fp_detail_label">Work Order</div>
<h2><span t-out="job.name"/></h2>
<div t-if="job.process_type_ids" class="o_fp_detail_subtitle">
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
</div>
<t t-if="part">
<div class="o_fp_detail_subtitle">
<t t-if="part.part_number"><b t-out="part.part_number"/> · </t>
<t t-out="part.name or part.display_name"/>
</div>
</t>
<t t-elif="job.process_type_ids">
<div class="o_fp_detail_subtitle">
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
</div>
</t>
<div class="o_fp_detail_facts">
<div t-if="job.quantity">
<span class="o_fp_fact_label">Qty </span>
@@ -503,6 +491,12 @@
<span class="o_fp_fact_label">Tracking </span>
<span class="o_fp_fact_value" t-out="job.tracking_ref"/>
</div>
<div t-if="ship_to and ship_to.id != job.partner_id.commercial_partner_id.id">
<span class="o_fp_fact_label">Ship to </span>
<span class="o_fp_fact_value">
<t t-out="ship_to.name"/><t t-if="ship_to.city"> · <t t-out="ship_to.city"/></t>
</span>
</div>
</div>
</div>
<div class="d-flex flex-column align-items-end gap-2">