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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 17:57:35 -04:00
parent 056178b433
commit 271a995455
3 changed files with 131 additions and 1 deletions

View File

@@ -4,6 +4,7 @@
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
import logging import logging
import re
from . import controllers from . import controllers
from . import models from . import models
@@ -282,6 +283,38 @@ _STARTER_KIND_BY_NAME = {
'ready for post-plate inspection': 'gating', 'ready for post-plate inspection': 'gating',
'ready for final inspection': 'gating', 'ready for final inspection': 'gating',
'ready for shipping': '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 case. Used by both the seeder and the migration backfill so we don't
have two slightly-different lookup paths. 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. Returns the kind str or None when no match.
""" """
if not name: if not name:
@@ -297,6 +333,12 @@ def fp_resolve_step_kind(name):
key = name.strip().lower() key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME: if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key] 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 # Gating "Ready for / Ready For" prefix — anything starting with that
# is a gating node regardless of the destination step name. # is a gating node regardless of the destination step name.
if key.startswith('ready for ') or key.startswith('ready '): if key.startswith('ready for ') or key.startswith('ready '):
@@ -304,6 +346,38 @@ def fp_resolve_step_kind(name):
return None 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): def _seed_step_library_if_empty(env):
"""Sub 12a — seed fp.step.template starter library. """Sub 12a — seed fp.step.template starter library.

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.21.2.0', 'version': '19.0.21.3.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -784,6 +784,62 @@ class FpProcessNode(models.Model):
return self.action_open_simple_editor() return self.action_open_simple_editor()
return self.action_open_tree_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) ----------------------------------------------- # ---- Copy (deep-duplicate) -----------------------------------------------
def copy(self, default=None): def copy(self, default=None):