From c637f82ae2b438a068679b3ae18f4c97378b9837 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 15 May 2026 01:23:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(promote-customer-spec):=20Phase=20C=20?= =?UTF-8?q?=E2=80=94=20pricing,=20quality,=20job,=20cert=20re-keyed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/sale_order.py | 7 ++ .../fusion_plating_quality/__manifest__.py | 3 +- .../fusion_plating_quality/models/__init__.py | 3 + .../models/fp_certificate_inherit.py | 61 +++++++++++++ .../models/fp_pricing_rule_inherit.py | 36 ++++++++ .../models/fp_quality_point.py | 30 ++++++- .../models/fp_quality_point_hooks.py | 22 ++++- .../models/fp_quote_configurator_inherit.py | 87 +++++++++++++++++++ .../views/fp_pricing_rule_views_inherit.xml | 29 +++++++ .../views/fp_quality_point_views.xml | 4 + 11 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 fusion_plating/fusion_plating_quality/models/fp_certificate_inherit.py create mode 100644 fusion_plating/fusion_plating_quality/models/fp_pricing_rule_inherit.py create mode 100644 fusion_plating/fusion_plating_quality/models/fp_quote_configurator_inherit.py create mode 100644 fusion_plating/fusion_plating_quality/views/fp_pricing_rule_views_inherit.xml 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"/> + +