feat(promote-customer-spec): Phase D — reports + tablet payload include spec

Reports updated to print Specification (with revision via display_name):
- report_fp_sale.xml — header sections show "SPECIFICATION" instead
  of "COATING CONFIG", reads doc.x_fc_customer_spec_id (added on
  sale.order via quality inherit, computed from line.customer_spec_id)
- report_fp_wo_sticker.xml — propagates _spec alongside _coating
- fusion_plating_reports/report_fp_job_traveller.xml — header row
  now shows Specification (falls back to coating)
- fusion_plating_jobs/report_fp_job_traveller.xml — same fall-back
- fusion_plating_jobs/report_fp_job_sticker.xml — _spec added

sale.order.x_fc_customer_spec_id added as a stored compute on
sale.order (in quality) so reports can render order-level spec.
Mirrors the line's first spec; updates on line edit.

Tablet payload (shopfloor_controller.py):
- spec_label added to the job payload dict
- defensive 'customer_spec_id' in job._fields check (shopfloor doesn't
  depend on quality — circular if added)

Portal: deferred (same circular-dep issue, more substantial UI rewrite
needed; Phase E backlog item).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-15 01:30:05 -04:00
parent c637f82ae2
commit e0eacc2530
11 changed files with 63 additions and 16 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.9.0.0', 'version': '19.0.9.1.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -57,6 +57,7 @@
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/> <t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/> <t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/> <t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/> <t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/> <t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/> <t t-set="_qty" t-value="job.qty"/>
@@ -99,6 +100,7 @@
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/> <t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/> <t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/> <t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/> <t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/> <t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/> <t t-set="_qty" t-value="job.qty"/>

View File

@@ -200,7 +200,10 @@
<t t-else=""></t> <t t-else=""></t>
</td> </td>
<td> <td>
<t t-if="'coating_config_id' in job._fields and job.coating_config_id"> <t t-if="'customer_spec_id' in job._fields and job.customer_spec_id">
<span t-esc="job.customer_spec_id.display_name"/>
</t>
<t t-elif="'coating_config_id' in job._fields and job.coating_config_id">
<span t-esc="job.coating_config_id.name"/> <span t-esc="job.coating_config_id.name"/>
</t> </t>
</td> </td>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Quality (QMS)', 'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.5.2.0', 'version': '19.0.5.3.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.', 'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -6,6 +6,29 @@
from odoo import api, fields, models from odoo import api, fields, models
class SaleOrder(models.Model):
"""Add an order-level Specification mirror so reports can print it
in the header summary section. Computed from the lines (first
spec wins; falls back to blank when lines have no spec).
"""
_inherit = 'sale.order'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
compute='_compute_x_fc_customer_spec_id',
store=True,
help='First specification cited on this order (or blank). '
'Drives the order-level header in customer-facing PDFs.',
)
@api.depends('order_line.x_fc_customer_spec_id')
def _compute_x_fc_customer_spec_id(self):
for so in self:
specs = so.order_line.mapped('x_fc_customer_spec_id')
so.x_fc_customer_spec_id = specs[:1] if specs else False
class SaleOrderLine(models.Model): class SaleOrderLine(models.Model):
"""Add the Specification picker to the SO line. """Add the Specification picker to the SO line.

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Reports', 'name': 'Fusion Plating — Reports',
'version': '19.0.10.16.0', 'version': '19.0.11.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.', 'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [ 'depends': [

View File

@@ -110,9 +110,12 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="info-header">Coating Config</th> <th class="info-header">Specification</th>
<td> <td>
<t t-if="so and so.x_fc_coating_config_id"> <t t-if="so and so.x_fc_customer_spec_id">
<span t-field="so.x_fc_customer_spec_id"/>
</t>
<t t-elif="so and so.x_fc_coating_config_id">
<span t-field="so.x_fc_coating_config_id"/> <span t-field="so.x_fc_coating_config_id"/>
</t> </t>
<t t-else=""></t> <t t-else=""></t>

View File

@@ -75,19 +75,19 @@
</table> </table>
<!-- Plating info --> <!-- Plating info -->
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id or doc.x_fc_delivery_method"> <t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id or doc.x_fc_delivery_method">
<table class="bordered"> <table class="bordered">
<thead> <thead>
<tr> <tr>
<th class="info-header" style="width: 34%;">PART</th> <th class="info-header" style="width: 34%;">PART</th>
<th class="info-header" style="width: 33%;">COATING CONFIG</th> <th class="info-header" style="width: 33%;">SPECIFICATION</th>
<th class="info-header" style="width: 33%;">DELIVERY METHOD</th> <th class="info-header" style="width: 33%;">DELIVERY METHOD</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td> <td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td> <td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
<td class="text-center"> <td class="text-center">
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/> <t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
<span t-esc="dm"/> <span t-esc="dm"/>
@@ -340,12 +340,12 @@
</table> </table>
<!-- Plating details --> <!-- Plating details -->
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id"> <t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id">
<table class="bordered info-table"> <table class="bordered info-table">
<thead> <thead>
<tr> <tr>
<th>PART CATALOG</th> <th>PART CATALOG</th>
<th>COATING CONFIGURATION</th> <th>SPECIFICATION</th>
<th>INVOICE STRATEGY</th> <th>INVOICE STRATEGY</th>
<th>DEPOSIT %</th> <th>DEPOSIT %</th>
</tr> </tr>
@@ -353,7 +353,7 @@
<tbody> <tbody>
<tr> <tr>
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td> <td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td> <td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
<td class="text-center"> <td class="text-center">
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/> <t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
<span t-esc="inv_strat"/> <span t-esc="inv_strat"/>

View File

@@ -19,7 +19,8 @@
* _mo — the mrp.production record (or False) * _mo — the mrp.production record (or False)
* _so, _line — the originating sale order / line * _so, _line — the originating sale order / line
* _part — fp.part.catalog * _part — fp.part.catalog
* _coating — fp.coating.config * _coating — fp.coating.config (legacy; removed in Phase E)
* _spec — fusion.plating.customer.spec (the audit-tracked spec the cert prints)
* _process — the resolved fusion.plating.process.node tree * _process — the resolved fusion.plating.process.node tree
* _due — datetime/date for "Due Date" row * _due — datetime/date for "Due Date" row
* _qty — float for "Qty" row * _qty — float for "Qty" row
@@ -48,6 +49,7 @@
or False"/> or False"/>
<t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/> <t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/>
<t t-set="_coating" t-value="_coating or (_line and _line.x_fc_coating_config_id) or False"/> <t t-set="_coating" t-value="_coating or (_line and _line.x_fc_coating_config_id) or False"/>
<t t-set="_spec" t-value="_spec or (_line and _line.x_fc_customer_spec_id) or False"/>
<t t-set="_process" t-value="_process <t t-set="_process" t-value="_process
or (_part and _part.default_process_id) or (_part and _part.default_process_id)
or (_coating and _coating.recipe_id) or (_coating and _coating.recipe_id)
@@ -469,6 +471,7 @@
<t t-set="_line" t-value="line"/> <t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/> <t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/> <t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/> <t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/> <t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/> <t t-set="_qty_total" t-value="line.product_uom_qty"/>
@@ -499,6 +502,7 @@
<t t-set="_line" t-value="line"/> <t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/> <t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/> <t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/> <t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/> <t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/> <t t-set="_qty_total" t-value="line.product_uom_qty"/>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.25.2.1', 'version': '19.0.26.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.', 'first-piece inspection gates.',

View File

@@ -1137,11 +1137,16 @@ class FpShopfloorController(http.Controller):
# Now we walk each unique job once and stash the answers. # Now we walk each unique job once and stash the answers.
unique_jobs = steps.mapped('job_id') unique_jobs = steps.mapped('job_id')
# Prefetch the fields we'll touch (saves N+1 SQL fetches) # Prefetch the fields we'll touch (saves N+1 SQL fetches)
unique_jobs.read([ # customer_spec_id present when fusion_plating_quality is installed
# (added there as an inherit on fp.job).
job_read_fields = [
'name', 'origin', 'priority', 'partner_id', 'product_id', 'name', 'origin', 'priority', 'partner_id', 'product_id',
'qty', 'qty_done', 'date_planned_start', 'date_deadline', 'qty', 'qty_done', 'date_planned_start', 'date_deadline',
'part_catalog_id', 'coating_config_id', 'part_catalog_id', 'coating_config_id',
]) ]
if 'customer_spec_id' in unique_jobs._fields:
job_read_fields.append('customer_spec_id')
unique_jobs.read(job_read_fields)
step_idx_by_id = {} # step_id → 1-based ordinal in its job step_idx_by_id = {} # step_id → 1-based ordinal in its job
job_step_count_by_id = {} # job_id → total step count job_step_count_by_id = {} # job_id → total step count
queued_start_by_step_id = {} # step_id → predecessor's date_finished queued_start_by_step_id = {} # step_id → predecessor's date_finished
@@ -1554,6 +1559,12 @@ class FpShopfloorController(http.Controller):
job.coating_config_id job.coating_config_id
if 'coating_config_id' in job._fields else False if 'coating_config_id' in job._fields else False
) )
# Specification (added by fusion_plating_quality)
spec = (
job.customer_spec_id
if 'customer_spec_id' in job._fields else False
)
spec_label = (spec.display_name if spec else '') or ''
part_number = '' part_number = ''
part_revision = '' part_revision = ''
if part: if part:
@@ -1622,6 +1633,7 @@ class FpShopfloorController(http.Controller):
'part_number': part_number, 'part_number': part_number,
'part_revision': part_revision, 'part_revision': part_revision,
'coating_label': coating_label, 'coating_label': coating_label,
'spec_label': spec_label,
# ISO deadline for sort tiebreaker (v19.0.24.8.0) # ISO deadline for sort tiebreaker (v19.0.24.8.0)
'date_deadline_iso': ( 'date_deadline_iso': (
_flds.Datetime.to_string(job.date_deadline) _flds.Datetime.to_string(job.date_deadline)