From 271a9954552c8033bd87b7edeffc4ac65c69116a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 17:57:35 -0400 Subject: [PATCH] feat(fusion_plating): extend resolver + auto-classify hook on process node Resolver (fp_resolve_step_kind) extensions: - New aliases: blasting/bead blast/media blast variants, adhesion testing, corrosion testing, lab testing, strip process, chemical conversion, trivalent chromate, plug the threaded holes, air dry, desmut, soak clean, cleaner, nickel strike/strip - Parenthetical suffix stripping - "Masking (If Required)" resolves through "masking", "Incoming Inspection (Standard)" through "incoming inspection", "Trivalent Chromate Conversion (A-14 / A)" through "trivalent chromate conversion" - New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test -> wet_process) so the resolver output lands on active kinds only Auto-classify hook on fusion.plating.process.node: - _fp_autoclassify_kind() upgrades kind_id when current is 'other' AND name resolves via the resolver. Idempotent - never overrides a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify - Wired into create() and write() (only fires when name or kind_id changed on write) - Side-effects: recipe duplication via copy() auto-corrects newly copied nodes; Simple/Tree editor authoring auto-classifies as soon as the name is saved Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/fusion_plating/__init__.py | 74 +++++++++++++++++++ fusion_plating/fusion_plating/__manifest__.py | 2 +- .../fusion_plating/models/fp_process_node.py | 56 ++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/fusion_plating/fusion_plating/__init__.py b/fusion_plating/fusion_plating/__init__.py index 8d76baf1..274fe4f8 100644 --- a/fusion_plating/fusion_plating/__init__.py +++ b/fusion_plating/fusion_plating/__init__.py @@ -4,6 +4,7 @@ # Part of the Fusion Plating product family. import logging +import re from . import controllers from . import models @@ -282,6 +283,38 @@ _STARTER_KIND_BY_NAME = { 'ready for post-plate inspection': 'gating', 'ready for final inspection': 'gating', 'ready for shipping': 'gating', + # 2026-05-24 — Recipe cleanup additions (spec + # 2026-05-24-recipe-cleanup-design.md). Covers names the existing + # resolver didn't know that turned up in the entech recipes audit. + # Blasting variants + 'blasting': 'blast', + 'bead blast': 'blast', + 'bead blasting': 'blast', + 'media blast': 'blast', + 'media blasting': 'blast', + # Inspection variants + 'adhesion test coupon': 'inspect', + 'adhesion testing': 'inspect', + 'corrosion testing': 'inspect', + 'lab testing': 'inspect', + 'check sulfamate nickel area': 'inspect', + 'pre-measurements': 'inspect', + 'pre measurements': 'inspect', + 'hot water porosity': 'inspect', + # Strip / chemical conversion / plugging (wet line) + 'strip process': 'wet_process', + 'strip process - al': 'wet_process', + 'nickel strip - aluminum line': 'wet_process', + 'chemical conversion': 'wet_process', + 'trivalent chromate conversion': 'wet_process', + 'plug the threaded holes': 'mask', + # Misc wet-line variants seen on entech recipes + 'air dry': 'dry', + 'desmut': 'etch', + 'soak clean': 'cleaning', + 'cleaner': 'cleaning', + 'nickel strike': 'plate', + 'nickel strip': 'plate', } @@ -290,6 +323,9 @@ def fp_resolve_step_kind(name): case. Used by both the seeder and the migration backfill so we don't have two slightly-different lookup paths. + Handles parenthetical suffixes like "(Standard)", "(If Required)", + "(A-14 / A)" by stripping them and re-trying the lookup. + Returns the kind str or None when no match. """ if not name: @@ -297,6 +333,12 @@ def fp_resolve_step_kind(name): key = name.strip().lower() if key in _STARTER_KIND_BY_NAME: return _STARTER_KIND_BY_NAME[key] + # Parenthetical strip — "Masking (If Required)" → "masking", + # "Incoming Inspection (Standard)" → "incoming inspection", + # "Trivalent Chromate Conversion (A-14 / A)" → "trivalent chromate conversion". + bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip() + if bare and bare != key and bare in _STARTER_KIND_BY_NAME: + return _STARTER_KIND_BY_NAME[bare] # Gating "Ready for / Ready For" prefix — anything starting with that # is a gating node regardless of the destination step name. if key.startswith('ready for ') or key.startswith('ready '): @@ -304,6 +346,38 @@ def fp_resolve_step_kind(name): return None +# Translates resolver kind output to the active fp.step.kind.code values. +# The resolver still returns the OLD vocabulary (cleaning, electroclean, +# etch, rinse, strike, dry, wbf_test) which were deactivated in +# 19.0.20.6.0 — those roll up to the active wet_process kind. Other +# codes pass through 1:1. Used by the auto-classify hook on +# fusion.plating.process.node + the recipe-cleanup migration +# (fusion_plating_jobs 19.0.10.26.0). +RESOLVER_KIND_TO_ACTIVE_KIND = { + # Wet-line kinds → wet_process (active rollup) + 'cleaning': 'wet_process', + 'electroclean': 'wet_process', + 'etch': 'wet_process', + 'rinse': 'wet_process', + 'strike': 'wet_process', + 'dry': 'wet_process', + 'wbf_test': 'wet_process', + # 1:1 mappings (kind exists and is active) + 'contract_review': 'contract_review', + 'mask': 'mask', + 'racking': 'racking', + 'plate': 'plate', + 'bake': 'bake', + 'derack': 'derack', + 'demask': 'demask', + 'inspect': 'inspect', + 'final_inspect': 'final_inspect', + 'ship': 'ship', + 'gating': 'gating', + 'blast': 'blast', +} + + def _seed_step_library_if_empty(env): """Sub 12a — seed fp.step.template starter library. diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 6eff726f..98947f2e 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.21.2.0', + 'version': '19.0.21.3.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py index b44d3fdc..1784f46a 100644 --- a/fusion_plating/fusion_plating/models/fp_process_node.py +++ b/fusion_plating/fusion_plating/models/fp_process_node.py @@ -784,6 +784,62 @@ class FpProcessNode(models.Model): return self.action_open_simple_editor() return self.action_open_tree_editor() + # ---- Auto-classify kind from name (2026-05-24, spec + # docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md) ------- + # Safety net: when a node's kind is the catch-all 'other' AND its + # name resolves via fp_resolve_step_kind(), upgrade kind_id to the + # resolved active kind. Runs on create() and on write() when name + # or kind_id changes. Prevents recipe authoring + recipe + # duplication from silently leaving nodes as 'other' (which then + # routes them to the wrong Shop Floor column). + # + # Skip with context flag fp_skip_kind_autoclassify=True for admin + # workflows that need to keep kind=other despite a known name. + + def _fp_autoclassify_kind(self): + """Upgrade kind_id when current is 'other' and name resolves.""" + if self.env.context.get('fp_skip_kind_autoclassify'): + return + from odoo.addons.fusion_plating import ( + fp_resolve_step_kind, + RESOLVER_KIND_TO_ACTIVE_KIND, + ) + Kind = self.env['fp.step.kind'] + other = Kind.search([('code', '=', 'other')], limit=1) + if not other: + return + # Cache active-kind ids by code so we don't re-search per row. + kind_by_code = {} + for node in self: + if not node.name or node.kind_id != other: + continue + resolver_code = fp_resolve_step_kind(node.name) + if not resolver_code: + continue + target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code) + if not target_code: + continue + if target_code not in kind_by_code: + tgt = Kind.search([('code', '=', target_code)], limit=1) + kind_by_code[target_code] = tgt.id if tgt else False + target_id = kind_by_code[target_code] + if target_id: + node.with_context( + fp_skip_kind_autoclassify=True, + ).write({'kind_id': target_id}) + + @api.model_create_multi + def create(self, vals_list): + nodes = super().create(vals_list) + nodes._fp_autoclassify_kind() + return nodes + + def write(self, vals): + res = super().write(vals) + if 'name' in vals or 'kind_id' in vals: + self._fp_autoclassify_kind() + return res + # ---- Copy (deep-duplicate) ----------------------------------------------- def copy(self, default=None):