feat(promote-customer-spec): Phase C — pricing, quality, job, cert re-keyed

Pricing:
- Quality inherit on fp.pricing.rule adds customer_spec_id + recipe_id
- Quality inherit on fp.quote.configurator adds customer_spec_id field
  + extends _find_matching_rule with priority chain:
    spec (+8) > recipe (+6) > coating (+4) > material (+2) > cert (+1)
- View inherit surfaces both new pickers on the rule form

Quality points:
- fp.quality.point now has customer_spec_ids + recipe_ids M2M filters
- Matcher (_matches + _find_matching) accepts new args
- Hook overrides on SO confirm + job confirm/done + step finish
  pass spec/recipe context through to the matcher
- View surfaces both new M2M widgets

Job:
- jobs/sale_order.py wires x_fc_customer_spec_id from SO line to
  fp.job.customer_spec_id on action_confirm

Cert:
- Quality inherit on fp.certificate adds customer_spec_id field +
  create() override auto-fills spec_reference from spec.code+revision
  Resolution priority: explicit spec_reference > cert.customer_spec_id
  > SO line spec (with print_on_cert) > legacy coating fallback

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

View File

@@ -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.',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Adds Specification + Recipe pickers to the pricing rule form.
Both fields live on this module's inherit (the customer.spec model
lives here).
-->
<odoo>
<record id="view_fp_pricing_rule_form_quality_inherit" model="ir.ui.view">
<field name="name">fp.pricing.rule.form.quality.spec.inherit</field>
<field name="model">fp.pricing.rule</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_fp_pricing_rule_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='coating_config_id']"
position="after">
<field name="customer_spec_id"
options="{'no_quick_create': True}"/>
<field name="recipe_id"
options="{'no_quick_create': True}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -65,6 +65,10 @@
placeholder="All parts if empty"/>
</group>
<group>
<field name="customer_spec_ids" widget="many2many_tags"
placeholder="All specs if empty"/>
<field name="recipe_ids" widget="many2many_tags"
placeholder="All recipes if empty"/>
<field name="coating_config_ids" widget="many2many_tags"
placeholder="All coatings if empty"/>
<field name="step_kind"