diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py
index e4e415b3..706d5e31 100644
--- a/fusion_plating/fusion_plating_jobs/__manifest__.py
+++ b/fusion_plating/fusion_plating_jobs/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
- 'version': '19.0.8.27.0',
+ 'version': '19.0.9.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py
index eb114c35..995c6027 100644
--- a/fusion_plating/fusion_plating_jobs/models/sale_order.py
+++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py
@@ -470,6 +470,11 @@ class SaleOrder(models.Model):
and first_line.x_fc_coating_config_id
or False
)
+ customer_spec = (
+ 'x_fc_customer_spec_id' in first_line._fields
+ and first_line.x_fc_customer_spec_id
+ or False
+ )
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields:
@@ -489,6 +494,8 @@ class SaleOrder(models.Model):
vals['part_catalog_id'] = part.id
if coating:
vals['coating_config_id'] = coating.id
+ if customer_spec:
+ vals['customer_spec_id'] = customer_spec.id
if recipe:
vals['recipe_id'] = recipe.id
diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py
index 5b0e4f42..b48547d6 100644
--- a/fusion_plating/fusion_plating_quality/__manifest__.py
+++ b/fusion_plating/fusion_plating_quality/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
- 'version': '19.0.5.1.0',
+ 'version': '19.0.5.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
@@ -94,6 +94,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/sale_order_views_inherit.xml',
'views/fp_part_catalog_views_inherit.xml',
'views/fp_direct_order_wizard_views_inherit.xml',
+ 'views/fp_pricing_rule_views_inherit.xml',
'views/fp_audit_views.xml',
'views/fp_fair_views.xml',
'views/fp_doc_control_views.xml',
diff --git a/fusion_plating/fusion_plating_quality/models/__init__.py b/fusion_plating/fusion_plating_quality/models/__init__.py
index eb579da1..19df6aff 100644
--- a/fusion_plating/fusion_plating_quality/models/__init__.py
+++ b/fusion_plating/fusion_plating_quality/models/__init__.py
@@ -13,6 +13,9 @@ from . import fp_process_node_inherit
from . import sale_order_line_inherit
from . import account_move_line_inherit
from . import fp_direct_order_line_inherit
+from . import fp_pricing_rule_inherit
+from . import fp_quote_configurator_inherit
+from . import fp_certificate_inherit
from . import fp_audit
from . import fp_fair
from . import fp_doc_control
diff --git a/fusion_plating/fusion_plating_quality/models/fp_certificate_inherit.py b/fusion_plating/fusion_plating_quality/models/fp_certificate_inherit.py
new file mode 100644
index 00000000..71ca6f8a
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_certificate_inherit.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+
+from odoo import api, fields, models
+
+
+class FpCertificate(models.Model):
+ """Add Specification linkage + auto-fill spec_reference from it.
+
+ Lives in fusion_plating_quality because customer.spec lives here.
+ Quality already depends on certificates, so the inverse direction
+ works.
+ """
+ _inherit = 'fp.certificate'
+
+ customer_spec_id = fields.Many2one(
+ 'fusion.plating.customer.spec',
+ string='Specification',
+ help='Snapshot of the specification the cert was issued against. '
+ 'Drives the spec_reference printed on the CoC.',
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Auto-fill spec_reference from the SO line's customer_spec_id.
+
+ Resolution order (first match wins):
+ 1. Explicit spec_reference passed in vals.
+ 2. customer_spec_id (this field) → format "code Rev rev".
+ 3. SO line x_fc_customer_spec_id (with print_on_cert=True).
+ 4. Existing legacy fall-back lives in the parent module
+ (reads x_fc_coating_config_id.spec_reference). Untouched.
+ """
+ SaleOrder = self.env['sale.order']
+ for vals in vals_list:
+ if vals.get('spec_reference'):
+ continue
+ spec = False
+ # 2. Explicit spec on the cert.
+ if vals.get('customer_spec_id'):
+ spec = self.env['fusion.plating.customer.spec'].browse(
+ vals['customer_spec_id'],
+ ).exists()
+ # 3. SO line's spec.
+ if not spec and vals.get('sale_order_id'):
+ so = SaleOrder.browse(vals['sale_order_id'])
+ if 'x_fc_customer_spec_id' in so.order_line._fields:
+ spec = so.order_line.mapped(
+ 'x_fc_customer_spec_id',
+ ).filtered('print_on_cert')[:1]
+ if spec and not vals.get('customer_spec_id'):
+ vals['customer_spec_id'] = spec.id
+ if spec:
+ ref = spec.code or ''
+ if spec.revision:
+ ref = f'{ref} Rev {spec.revision}' if ref else f'Rev {spec.revision}'
+ if ref:
+ vals['spec_reference'] = ref
+ return super().create(vals_list)
diff --git a/fusion_plating/fusion_plating_quality/models/fp_pricing_rule_inherit.py b/fusion_plating/fusion_plating_quality/models/fp_pricing_rule_inherit.py
new file mode 100644
index 00000000..6fd44203
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_pricing_rule_inherit.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+
+from odoo import fields, models
+
+
+class FpPricingRule(models.Model):
+ """Add Specification + Recipe match keys to the pricing rule.
+
+ Lives in fusion_plating_quality because fusion.plating.customer.spec
+ lives here. Rules can now match on:
+ - customer_spec_id (most specific — e.g. "AMS 2404 surcharge")
+ - recipe_id (recipe-tier — e.g. "EN Mid-Phos $X/sqft")
+ - both blank (fallback — material/cert-level matching)
+
+ The configurator's matcher is extended in fp_quote_configurator_inherit.
+ """
+ _inherit = 'fp.pricing.rule'
+
+ customer_spec_id = fields.Many2one(
+ 'fusion.plating.customer.spec',
+ string='Specification',
+ help='Match rule against the SO line specification. Combine with '
+ 'recipe_id for spec+recipe specific pricing, or leave recipe '
+ 'blank for spec-tier pricing.',
+ )
+ recipe_id = fields.Many2one(
+ 'fusion.plating.process.node',
+ string='Recipe',
+ domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
+ help='Match rule against the SO line recipe. Combine with '
+ 'customer_spec_id for spec+recipe specific pricing, or '
+ 'leave spec blank for recipe-tier pricing.',
+ )
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_point.py b/fusion_plating/fusion_plating_quality/models/fp_quality_point.py
index f4ef7df7..0713b329 100644
--- a/fusion_plating/fusion_plating_quality/models/fp_quality_point.py
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_point.py
@@ -67,6 +67,23 @@ class FpQualityPoint(models.Model):
'fp.coating.config', 'fp_quality_point_coating_rel',
'point_id', 'coating_id', string='Coatings',
)
+ customer_spec_ids = fields.Many2many(
+ 'fusion.plating.customer.spec',
+ 'fp_quality_point_spec_rel',
+ 'point_id', 'spec_id',
+ string='Specifications',
+ help='If set, this trigger only fires for SOs / jobs whose '
+ 'specification is in this list. Leave blank to ignore spec.',
+ )
+ recipe_ids = fields.Many2many(
+ 'fusion.plating.process.node',
+ 'fp_quality_point_recipe_rel',
+ 'point_id', 'recipe_id',
+ domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
+ string='Recipes',
+ help='If set, this trigger only fires for jobs running one of '
+ 'these recipes. Leave blank to ignore recipe.',
+ )
step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
template_id = fields.Many2one(
@@ -102,7 +119,8 @@ class FpQualityPoint(models.Model):
# ------------------------------------------------------------------
# Matching + spawning
# ------------------------------------------------------------------
- def _matches(self, partner=None, part=None, coating=None, step=None):
+ def _matches(self, partner=None, part=None, coating=None, step=None,
+ customer_spec=None, recipe=None):
"""Return True if this point's filters all pass against the supplied
context. Empty filter == match anything.
"""
@@ -115,6 +133,13 @@ class FpQualityPoint(models.Model):
if self.coating_config_ids and (
not coating or coating not in self.coating_config_ids):
return False
+ if self.customer_spec_ids and (
+ not customer_spec
+ or customer_spec not in self.customer_spec_ids):
+ return False
+ if self.recipe_ids and (
+ not recipe or recipe not in self.recipe_ids):
+ return False
if self.step_kind and step and getattr(step, 'kind', None) \
and step.kind != self.step_kind:
return False
@@ -122,7 +147,7 @@ class FpQualityPoint(models.Model):
@api.model
def _find_matching(self, trigger, partner=None, part=None, coating=None,
- step=None):
+ step=None, customer_spec=None, recipe=None):
"""Return active points whose trigger + filters match the context."""
candidates = self.search([
('active', '=', True),
@@ -130,6 +155,7 @@ class FpQualityPoint(models.Model):
])
return candidates.filtered(lambda p: p._matches(
partner=partner, part=part, coating=coating, step=step,
+ customer_spec=customer_spec, recipe=recipe,
))
def _spawn_check_for(self, source, partner=None, job=None, step=None):
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_point_hooks.py b/fusion_plating/fusion_plating_quality/models/fp_quality_point_hooks.py
index 264bb818..2bb2b209 100644
--- a/fusion_plating/fusion_plating_quality/models/fp_quality_point_hooks.py
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_point_hooks.py
@@ -49,22 +49,28 @@ class SaleOrderPointHook(models.Model):
Point = self.env['fp.quality.point']
for so in self:
partner = so.partner_id
- # Walk lines for part / coating context.
+ # Walk lines for part / coating / spec context.
parts = so.order_line.mapped('x_fc_part_catalog_id') \
if 'x_fc_part_catalog_id' in so.order_line._fields else False
coatings = so.order_line.mapped('x_fc_coating_config_id') \
if 'x_fc_coating_config_id' in so.order_line._fields else False
+ specs = so.order_line.mapped('x_fc_customer_spec_id') \
+ if 'x_fc_customer_spec_id' in so.order_line._fields else False
points = Point._find_matching(
trigger='so_confirmed', partner=partner,
)
for point in points:
- # Filter by part / coating intersection if the point cares.
+ # Filter by part / coating / spec intersection if the
+ # point cares.
if point.part_catalog_ids and parts and \
not (point.part_catalog_ids & parts):
continue
if point.coating_config_ids and coatings and \
not (point.coating_config_ids & coatings):
continue
+ if point.customer_spec_ids and specs and \
+ not (point.customer_spec_ids & specs):
+ continue
point._spawn_check_for(source=so, partner=partner)
return result
@@ -80,9 +86,13 @@ class FpJobPointHook(models.Model):
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
+ customer_spec = getattr(job, 'customer_spec_id', False) or False
+ recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching(
trigger='job_confirmed', partner=partner,
part=part or None, coating=coating or None,
+ customer_spec=customer_spec or None,
+ recipe=recipe or None,
)
for point in points:
point._spawn_check_for(
@@ -99,9 +109,13 @@ class FpJobPointHook(models.Model):
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
+ customer_spec = getattr(job, 'customer_spec_id', False) or False
+ recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching(
trigger='job_done', partner=partner,
part=part or None, coating=coating or None,
+ customer_spec=customer_spec or None,
+ recipe=recipe or None,
)
for point in points:
point._spawn_check_for(
@@ -124,9 +138,13 @@ class FpJobStepPointHook(models.Model):
partner = job.partner_id if job else False
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
+ customer_spec = getattr(job, 'customer_spec_id', False) or False
+ recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching(
trigger='job_step_done', partner=partner,
part=part or None, coating=coating or None, step=step,
+ customer_spec=customer_spec or None,
+ recipe=recipe or None,
)
for point in points:
point._spawn_check_for(
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quote_configurator_inherit.py b/fusion_plating/fusion_plating_quality/models/fp_quote_configurator_inherit.py
new file mode 100644
index 00000000..ec7fdc92
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quote_configurator_inherit.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+
+from odoo import fields, models
+
+
+class FpQuoteConfigurator(models.Model):
+ """Add Specification field + extend the pricing rule matcher.
+
+ Lives in fusion_plating_quality because customer.spec lives here.
+ """
+ _inherit = 'fp.quote.configurator'
+
+ customer_spec_id = fields.Many2one(
+ 'fusion.plating.customer.spec',
+ string='Specification',
+ help='Customer / industry spec the quote is built against. '
+ 'Drives pricing rule lookup and certificate auto-fill.',
+ )
+
+ def _find_matching_rule(self):
+ """Extend the configurator's matcher to consider Spec + Recipe.
+
+ Spec match adds +8 (highest priority — explicit customer spec
+ wins over chemistry / cert-level filters). Recipe adds +6.
+ Falls through to the existing coating / material / cert scoring.
+ """
+ # Cache the recipe before super (super may overwrite via thickness
+ # logic in some inherit chains).
+ recipe = (
+ self.coating_config_id.recipe_id
+ if self.coating_config_id and self.coating_config_id.recipe_id
+ else False
+ )
+ # Build the candidate rule set the same way super does — but
+ # since super uses a private mechanism we re-implement to keep
+ # the spec/recipe scoring inline with the rest.
+ builder_rules = (
+ recipe.pricing_rule_ids
+ if recipe else self.env['fp.pricing.rule']
+ )
+ if builder_rules:
+ rules = builder_rules.filtered('active').sorted(
+ lambda r: (r.sequence, r.id)
+ )
+ else:
+ rules = self.env['fp.pricing.rule'].search(
+ [('active', '=', True)], order='sequence, id'
+ )
+ cert_level = (
+ self.coating_config_id.certification_level
+ if self.coating_config_id else False
+ )
+
+ best = None
+ best_score = -1
+ for rule in rules:
+ score = 0
+ # NEW — spec wins biggest
+ if rule.customer_spec_id:
+ if rule.customer_spec_id != self.customer_spec_id:
+ continue
+ score += 8
+ # NEW — recipe is next
+ if rule.recipe_id:
+ if rule.recipe_id != recipe:
+ continue
+ score += 6
+ # Legacy — coating / material / cert
+ if rule.coating_config_id:
+ if rule.coating_config_id != self.coating_config_id:
+ continue
+ score += 4
+ if rule.substrate_material:
+ if rule.substrate_material != self.substrate_material:
+ 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
+ return best
diff --git a/fusion_plating/fusion_plating_quality/views/fp_pricing_rule_views_inherit.xml b/fusion_plating/fusion_plating_quality/views/fp_pricing_rule_views_inherit.xml
new file mode 100644
index 00000000..6caf0664
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/views/fp_pricing_rule_views_inherit.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+ fp.pricing.rule.form.quality.spec.inherit
+ fp.pricing.rule
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_quality/views/fp_quality_point_views.xml b/fusion_plating/fusion_plating_quality/views/fp_quality_point_views.xml
index 6e3bd1ce..8436a0ab 100644
--- a/fusion_plating/fusion_plating_quality/views/fp_quality_point_views.xml
+++ b/fusion_plating/fusion_plating_quality/views/fp_quality_point_views.xml
@@ -65,6 +65,10 @@
placeholder="All parts if empty"/>
+
+