From d4e95dcd4764d72218a2cadd6095f9a860c00f5a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 18:08:46 -0400 Subject: [PATCH] docs(plating): spec + plan for recipe cleanup + receiving enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes documented: 1. Recipe 3620 ENP-ALUM-BASIC had duplicate sequences (Contract Review + Masking both at seq 10; Incoming Inspection + Racking both at seq 20). Clones inherited the ambiguity and resolved by id ordering, putting Masking before Incoming Inspection — which meant new jobs went straight to Plating column after the contract-review auto-complete. 2. 24 per-part clone recipes accumulated, all carrying the broken ordering. 3. ~10 kind=other stragglers across the base recipes (Blasting, Adhesion Test Coupon, Strip Process, Chemical Conversion etc.) 4. Recipe duplication had no kind safety net. Implementation shipped in commits referenced from the plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-24-recipe-cleanup-plan.md | 784 ++++++++++++++++++ .../specs/2026-05-24-recipe-cleanup-design.md | 384 +++++++++ 2 files changed, 1168 insertions(+) create mode 100644 fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md create mode 100644 fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md diff --git a/fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md new file mode 100644 index 00000000..7bdb869f --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md @@ -0,0 +1,784 @@ +# Recipe Cleanup + Receiving Enforcement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix recipe 3620 ENP-ALUM-BASIC's duplicate-sequence bug, delete all 24 per-part clone recipes, backfill `kind=other` nodes via an extended name resolver, add an auto-classify hook on every node create/write, and make `no_parts` cards always land in the Receiving column. + +**Architecture:** One migration in `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` does all the data work in 5 phases (resequence 3620 → backfill kinds → delete clones → recompute step.area_kind → recompute job.active_step_id + card_state). Two code-side changes: extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping, add `_fp_autoclassify_kind()` to `fusion.plating.process.node.create/write` so future authoring + recipe duplication self-correct. + +**Spec:** [docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md](../specs/2026-05-24-recipe-cleanup-design.md) + +**Tech Stack:** Odoo 19, Python (ORM/migrations), PostgreSQL. + +--- + +## File Inventory + +| Path | Responsibility | +|---|---| +| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME` aliases; add parenthetical-strip to `fp_resolve_step_kind()`; expose `RESOLVER_KIND_TO_ACTIVE_KIND` map | +| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + create/write hooks | +| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` | +| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase data migration | +| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` | +| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving column override in `_resolve_card_area` | +| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` | + +--- + +## Task 1: Extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping + +**Files:** +- Modify: `fusion_plating/__init__.py:208-304` + +- [ ] **Step 1: Add `re` to imports** + +At the top of `fusion_plating/__init__.py`, after the existing `import logging` line, add: + +```python +import re +``` + +- [ ] **Step 2: Extend `_STARTER_KIND_BY_NAME`** + +Find the dict at line 208. Inside the dict (before the closing `}`), add the following keys (preserve the existing entries): + +```python + # 2026-05-24 — Recipe cleanup additions (live-step fix follow-up). + # Blasting variants + 'blasting': 'blast', + 'bead blast': 'blast', + 'bead blasting': 'blast', + 'media blast': 'blast', + 'media blasting': 'blast', + # Inspection variants the resolver didn't know + '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', +``` + +- [ ] **Step 3: Add parenthetical stripping inside `fp_resolve_step_kind()`** + +Find the function around line 288. Replace its body: + +```python +def fp_resolve_step_kind(name): + """Resolve a step name to a default_kind, tolerant of whitespace and + 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 before the second lookup attempt. + + Returns the kind str or None when no match. + """ + if not name: + return None + 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". + 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 '): + return 'gating' + return None +``` + +- [ ] **Step 4: Add `RESOLVER_KIND_TO_ACTIVE_KIND` translation map** + +Right after the `fp_resolve_step_kind` function (around line 305), add: + +```python +# Translates the resolver's 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. +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', +} +``` + +- [ ] **Step 5: Confirm import structure (no commit yet)** + +Run: +```bash +grep -n "^import re\|^from\|^import" fusion_plating/fusion_plating/__init__.py | head -5 +``` +Expected: `import re` appears before `from . import controllers`. + +Commit happens in Task 3. + +--- + +## Task 2: Auto-classify hook on `fusion.plating.process.node` + +**Files:** +- Modify: `fusion_plating/models/fp_process_node.py` + +- [ ] **Step 1: Find an insertion point near the existing `create/write/copy` methods** + +In [`fusion_plating/models/fp_process_node.py`](../../fusion_plating/models/fp_process_node.py), find the `copy()` method around line 789 (it's at the bottom of the FpProcessNode class). The autoclassify helper goes near it, and the create/write overrides slot in alongside copy. + +- [ ] **Step 2: Add the helper + create/write overrides** + +In `FpProcessNode`, add this block right before the `copy()` method at line ~787. Insert AFTER all the other fields/methods but BEFORE `copy()`: + +```python + # ---- Auto-classify kind from name (2026-05-24) ---------------------- + # 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 + 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 + target = Kind.search([('code', '=', target_code)], limit=1) + if target: + 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 + +``` + +- [ ] **Step 3: Verify the file parses (no commit yet)** + +Run: +```bash +python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating/models/fp_process_node.py').read()); print('OK')" +``` +Expected: `OK`. + +Commit happens in Task 3. + +--- + +## Task 3: Version bump fusion_plating + commit Phase 1 + +**Files:** +- Modify: `fusion_plating/__manifest__.py` + +- [ ] **Step 1: Bump the version** + +In [`fusion_plating/__manifest__.py`](../../fusion_plating/__manifest__.py), change: + +```python +'version': '19.0.21.2.0', +``` + +to: + +```python +'version': '19.0.21.3.0', +``` + +- [ ] **Step 2: Commit Phase 1** + +```bash +git add fusion_plating/fusion_plating/__init__.py \ + fusion_plating/fusion_plating/models/fp_process_node.py \ + fusion_plating/fusion_plating/__manifest__.py +git commit -m "$(cat <<'EOF' +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" +- 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 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Write the 19.0.10.26.0 migration + +**Files:** +- Create: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` + +- [ ] **Step 1: Create the migration directory** + +```bash +mkdir -p fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0 +``` + +- [ ] **Step 2: Write the migration file** + +Create `fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""19.0.10.26.0 — Recipe cleanup + per-part clone delete. + +Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md + +Phases (in order): + 1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the + duplicate empty ENP-Alum Line sub_process (id 4056). + 2. Backfill kind on all kind=other nodes via the extended + fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND + translation. + 3. Delete all 24 per-part clone recipes (name ILIKE '% — %'). + CASCADE handles child nodes; SET NULL handles fp.job / + fp.job.step / fp.coating.config / fp.pricing.rule / + fp.part.catalog references. + 4. Recompute fp.job.step.area_kind on all rows. + 5. Recompute fp.job.active_step_id + card_state on in-flight jobs. + +All phases idempotent — re-running -u is safe. +""" +import logging + +from odoo.api import Environment, SUPERUSER_ID + +_logger = logging.getLogger(__name__) + + +# Recipe 3620's ops in the desired final order. Maps the existing node +# id (as documented in the spec) to its target sequence. The user +# decided mask-first-then-rack per spec Section "Mask vs Rack order". +RECIPE_3620_RESEQUENCE = [ + # (node_id, new_sequence, expected_name) + (3853, 10, 'Contract Review'), + (3854, 20, 'Incoming Inspection (Standard)'), + (3877, 30, 'Masking'), + (3855, 40, 'Racking'), + (3858, 50, 'Ready for processing'), + (3859, 60, 'ENP-Alum Line'), + (3861, 70, 'De-Masking'), + (3864, 80, 'Oven baking'), + (3867, 90, 'De-racking'), + (4067, 100, 'Oven bake (Post de-rack)'), + (3873, 110, 'Post-plate Inspection'), + (3876, 120, 'Final Inspection'), +] + +# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no +# children — the real one is id 3859 with E-Nickel Plating as child). +RECIPE_3620_DUPLICATE_TO_DELETE = 4056 + + +def migrate(cr, version): + env = Environment(cr, SUPERUSER_ID, {}) + + # ============================================================ + # Phase 1 — Resequence recipe 3620 + delete duplicate sub_process + # ============================================================ + Node = env['fusion.plating.process.node'] + recipe_3620 = Node.browse(3620).exists() + if not recipe_3620: + _logger.warning( + '[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; ' + 'skipping resequence phase' + ) + else: + # Verify the expected nodes exist, then resequence them. + # We do this idempotently — only update if the sequence + # differs from the target. + renumbered = 0 + for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE: + node = Node.browse(node_id).exists() + if not node: + _logger.warning( + '[recipe-cleanup] Recipe 3620: expected node %s ' + '("%s") not found; skipping', + node_id, expected_name, + ) + continue + if node.sequence != new_seq: + # Skip the autoclassify hook on this write (nothing + # changes about kind_id; we're only touching sequence). + node.with_context( + fp_skip_kind_autoclassify=True, + ).write({'sequence': new_seq}) + renumbered += 1 + _logger.info( + '[recipe-cleanup] Recipe 3620: %s nodes resequenced', + renumbered, + ) + + # Delete the empty duplicate ENP-Alum Line sub_process. + dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists() + if dup: + if dup.child_ids: + _logger.warning( + '[recipe-cleanup] Duplicate sub_process %s has ' + '%s children — NOT deleting (safety check). ' + 'Expected an empty node.', + dup.id, len(dup.child_ids), + ) + else: + dup.unlink() + _logger.info( + '[recipe-cleanup] Deleted empty duplicate ' + 'ENP-Alum Line sub_process (id %s)', + RECIPE_3620_DUPLICATE_TO_DELETE, + ) + + # ============================================================ + # Phase 2 — Backfill kind on all kind=other nodes via resolver + # ============================================================ + from odoo.addons.fusion_plating import ( + fp_resolve_step_kind, + RESOLVER_KIND_TO_ACTIVE_KIND, + ) + Kind = env['fp.step.kind'] + other_kind = Kind.search([('code', '=', 'other')], limit=1) + if not other_kind: + _logger.error( + '[recipe-cleanup] No "other" kind found; skipping kind ' + 'backfill phase' + ) + else: + # Build a cache of code → kind.id so we don't search per-row + kind_by_code = {k.code: k.id for k in Kind.search([])} + affected_nodes = Node.search([ + ('kind_id', '=', other_kind.id), + ('name', '!=', False), + ('node_type', 'in', ('operation', 'step', 'sub_process')), + ]) + fixed = 0 + for node in affected_nodes: + 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 or target_code not in kind_by_code: + continue + node.with_context( + fp_skip_kind_autoclassify=True, + ).write({'kind_id': kind_by_code[target_code]}) + fixed += 1 + _logger.info( + '[recipe-cleanup] Phase 2: backfilled kind on %s nodes ' + '(of %s currently kind=other)', + fixed, len(affected_nodes), + ) + + # ============================================================ + # Phase 3 — Delete all 24 per-part clone recipes + # ============================================================ + # Identify by name pattern. The configurator names clones + # "BASE_NAME — PART_NUMBER Rev X" with an em-dash separator. + # No base recipe uses em-dash in its name. + clone_recipes = Node.search([ + ('node_type', '=', 'recipe'), + ('name', 'ilike', '% — %'), + ]) + if clone_recipes: + # Log what we're about to delete for forensic visibility. + clone_names = [c.name for c in clone_recipes] + _logger.info( + '[recipe-cleanup] Phase 3: deleting %s clone recipes: %s', + len(clone_recipes), + ', '.join(clone_names[:10]) + + (' …' if len(clone_names) > 10 else ''), + ) + clone_recipes.unlink() + _logger.info( + '[recipe-cleanup] Phase 3: deleted %s clone recipes ' + '(CASCADE removed their child nodes; FK SET NULL applied ' + 'to historical fp.job + fp.job.step references)', + len(clone_recipes), + ) + else: + _logger.info( + '[recipe-cleanup] Phase 3: no clone recipes found ' + '(already deleted on a prior run, or none exist)' + ) + + # ============================================================ + # Phase 4 — Recompute area_kind on all fp.job.step rows + # ============================================================ + # After Phase 2, many recipe nodes have new kinds. After Phase 3, + # some fp.job.step rows have NULL recipe_node_id (FK SET NULL'd + # when the clone got deleted). Recompute picks up the new kinds + # for active recipes and falls back to catch-all 'plating' for + # orphans (all historical / terminal jobs — won't show on board). + Step = env['fp.job.step'] + steps = Step.search([]) + if steps: + steps._compute_area_kind() + steps.flush_recordset(['area_kind']) + _logger.info( + '[recipe-cleanup] Phase 4: recomputed area_kind on %s steps', + len(steps), + ) + + # ============================================================ + # Phase 5 — Recompute active_step_id + card_state on in-flight jobs + # ============================================================ + Job = env['fp.job'] + jobs = Job.search([ + ('state', 'in', ('confirmed', 'in_progress')), + ]) + if jobs: + jobs._compute_active_step_id() + jobs._compute_card_state() + jobs.flush_recordset(['active_step_id', 'card_state']) + _logger.info( + '[recipe-cleanup] Phase 5: recomputed active_step_id + ' + 'card_state on %s in-flight jobs', + len(jobs), + ) +``` + +- [ ] **Step 3: Verify the file parses** + +Run: +```bash +python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py').read()); print('OK')" +``` +Expected: `OK`. + +--- + +## Task 5: Version bump fusion_plating_jobs + +**Files:** +- Modify: `fusion_plating_jobs/__manifest__.py` + +- [ ] **Step 1: Bump the version** + +In [`fusion_plating_jobs/__manifest__.py`](../../fusion_plating_jobs/__manifest__.py), change: + +```python +'version': '19.0.10.25.0', +``` + +to: + +```python +'version': '19.0.10.26.0', +``` + +- [ ] **Step 2: No commit yet — grouped with Task 6's commit.** + +--- + +## Task 6: `no_parts` cards always show in Receiving column + +**Files:** +- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py:165-180` +- Modify: `fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Update `_resolve_card_area`** + +In [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../fusion_plating_shopfloor/controllers/plant_kanban.py), find `_resolve_card_area` (around line 165). Replace its body with: + +```python +def _resolve_card_area(job): + """Pick the column a card lives in. + + Active-step area_kind wins, EXCEPT for no_parts cards which always + land in Receiving regardless of active step — the receiver is who + needs to act, and they work the Receiving column. With the live-step + priority chain (see fp.job._compute_active_step_id), active_step_id + is False only when the job has NO steps at all (recipe not assigned) + OR every step is `done`. Done jobs are filtered off the board + upstream, so the orphan fallback fires only for truly orphaned cards. + + See spec 2026-05-24-recipe-cleanup-design.md Change 6. + """ + # no_parts cards belong in Receiving regardless of where the active + # step is — the receiver is who acts. + if job.card_state == 'no_parts': + return 'receiving' + if job.active_step_id and job.active_step_id.area_kind: + return job.active_step_id.area_kind + # Orphan fallback — represents a data integrity issue, not a + # normal state. Cards here have NO steps assigned at all. + return 'receiving' +``` + +- [ ] **Step 2: Bump fusion_plating_shopfloor manifest** + +In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py): + +```python +'version': '19.0.33.1.3', +``` + +to: + +```python +'version': '19.0.33.1.4', +``` + +- [ ] **Step 3: Commit Phase 2 (Tasks 4-6)** + +```bash +git add fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \ + fusion_plating/fusion_plating_jobs/__manifest__.py \ + fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "$(cat <<'EOF' +feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix + +Migration 19.0.10.26.0/post-migrate.py runs in 5 phases: +1. Resequence recipe 3620 ENP-ALUM-BASIC ops (fixes the duplicate- + sequence bug that caused WO-30057 to skip Receiving) +2. Backfill kind on all kind=other nodes via the extended resolver + from fusion_plating 19.0.21.3.0 +3. Delete all 24 per-part clone recipes +4. Recompute fp.job.step.area_kind on all steps +5. Recompute fp.job.active_step_id + card_state on in-flight jobs + +Plant kanban: no_parts cards now always land in the Receiving column +regardless of active_step area_kind. The receiver works Receiving; +that's where the card belongs when parts haven't arrived. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Deploy to entech + verify + +- [ ] **Step 1: Fetch + check concurrent commits** + +```bash +git fetch origin +git log HEAD..origin/main --oneline +``` +Expected: empty (we're ahead, not behind). If anything shows, rebase first. + +- [ ] **Step 2: Copy modified files to entech** + +```bash +for f in \ + fusion_plating/__init__.py \ + fusion_plating/models/fp_process_node.py \ + fusion_plating/__manifest__.py \ + fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \ + fusion_plating_jobs/__manifest__.py \ + fusion_plating_shopfloor/controllers/plant_kanban.py \ + fusion_plating_shopfloor/__manifest__.py; do + echo "Copying $f" + cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \"mkdir -p \\\$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f\"" +done +echo "=== ALL COPIED ===" +``` + +(Run from `/Users/gurpreet/Github/Odoo-Modules/fusion_plating/` so the file paths line up.) + +- [ ] **Step 3: Upgrade modules + restart** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -60 && systemctl start odoo && sleep 3 && systemctl is-active odoo'" +``` + +Expected log lines (in order): +- `[recipe-cleanup] Recipe 3620: N nodes resequenced` +- `[recipe-cleanup] Deleted empty duplicate ENP-Alum Line sub_process (id 4056)` +- `[recipe-cleanup] Phase 2: backfilled kind on N nodes …` +- `[recipe-cleanup] Phase 3: deleting 24 clone recipes: …` +- `[recipe-cleanup] Phase 3: deleted 24 clone recipes …` +- `[recipe-cleanup] Phase 4: recomputed area_kind on N steps` +- `[recipe-cleanup] Phase 5: recomputed active_step_id + card_state on N in-flight jobs` +- Service prints `active` at the end. + +No tracebacks. If you see one, STOP and report it. + +- [ ] **Step 4: SQL spot-check — clones deleted** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT COUNT(*) AS clones_remaining FROM fusion_plating_process_node WHERE node_type='\\''recipe'\\'' AND name ILIKE '\\''% — %'\\'';\" | sudo -u postgres psql -d admin'" +``` +Expected: `clones_remaining = 0`. + +- [ ] **Step 5: SQL spot-check — recipe 3620 resequenced** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT sequence, name FROM fusion_plating_process_node WHERE parent_id = 3620 AND node_type IN ('\\''operation'\\'', '\\''sub_process'\\'') ORDER BY sequence;\" | sudo -u postgres psql -d admin'" +``` +Expected output (12 unique-sequence rows): +``` + 10 | Contract Review + 20 | Incoming Inspection (Standard) + 30 | Masking + 40 | Racking + 50 | Ready for processing + 60 | ENP-Alum Line + 70 | De-Masking + 80 | Oven baking + 90 | De-racking +100 | Oven bake (Post de-rack) +110 | Post-plate Inspection +120 | Final Inspection +``` +NO duplicate sequences. NO second ENP-Alum Line row. + +- [ ] **Step 6: SQL spot-check — kind=other nodes backfilled** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT n.name, COUNT(*) AS still_other FROM fusion_plating_process_node n JOIN fp_step_kind k ON k.id = n.kind_id WHERE k.code = '\\''other'\\'' AND n.node_type IN ('\\''operation'\\'', '\\''step'\\'', '\\''sub_process'\\'') GROUP BY n.name ORDER BY still_other DESC;\" | sudo -u postgres psql -d admin'" +``` +Expected: very few rows, only names like `ENP-Alum Line - HP` (sub_process with no clear category) or genuinely-niche operation names. Should NOT include `Contract Review`, `Masking`, `Racking`, `Incoming Inspection`, `E-Nickel Plating`, `Final Inspection`, `Shipping`, `Bake`, `Blasting`, `De-Masking`, `De-racking`, `Hot Water Porosity`, etc. + +- [ ] **Step 7: End-to-end smoke** + +On the entech UI: +1. Open Plating → Sales & Quoting → Sale Orders → New +2. Add a customer + a part whose default recipe is `ENP-ALUM-BASIC` (id 3620) +3. Confirm the SO +4. Check the new WO on Plating → Operations → Plating Jobs: + a. Recipe should be a fresh clone named `ENP-ALUM-BASIC — Rev ` + b. The clone's first 4 operations should be: Contract Review (10), Incoming Inspection (20), Masking (30), Racking (40) +5. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' AND/OR because Incoming Inspection is now the next live step after Contract Review auto-completes) +6. Open Plating → Configuration → Recipes & Steps → Recipes — confirm no recipe has " — " in its name (the clones are gone) + +- [ ] **Step 8: Autoclassify hook smoke** + +In the Simple Editor on any recipe: +1. Drop a new step, type name "Masking" without picking a kind +2. Save +3. Refresh the page +4. Confirm the step's kind reads "Masking" (not "Other") + +--- + +## Task 8: Commit spec + plan, push to origin + +- [ ] **Step 1: Stage and commit the spec + plan docs** + +```bash +git add fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md \ + fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md +git commit -m "$(cat <<'EOF' +docs(plating): spec + plan for recipe cleanup + receiving enforcement + +Spec documents: +- Root cause 1: duplicate sequences on recipe 3620 ENP-ALUM-BASIC +- Root cause 2: 24 per-part clone recipes carrying the broken order +- Root cause 3: ~10 kind=other stragglers across base recipes +- Root cause 4: recipe duplication has no kind safety net + +Implementation shipped in commits referenced from the plan's task list. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 2: Final fetch + push** + +```bash +git fetch origin +git log HEAD..origin/main --oneline # expect empty +git push origin main +``` + +--- + +## Rollback + +If anything fails on entech: + +1. `git reset --hard ` locally, force-copy the prior files back to entech. +2. Force-rerun the prior version's post-migrate by setting `ir_module_module.latest_version` back to `19.0.10.25.0` for fusion_plating_jobs and `19.0.21.2.0` for fusion_plating, then `-u`. + +(Migration is idempotent so re-running the broken version is safe; you may need to manually re-create the deleted clones from a DB backup if rollback needed clones back — out of scope per "we don't need to worry about current data".) diff --git a/fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md b/fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md new file mode 100644 index 00000000..4a88c056 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md @@ -0,0 +1,384 @@ +# Recipe Cleanup + Receiving Enforcement + +**Date:** 2026-05-24 +**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor` +**Status:** Approved, awaiting implementation plan. + +--- + +## Problem + +User created SO-30057, confirmed it, and the resulting WO-30057 went **straight to the Plating column** on the Shop Floor board — skipping Receiving entirely. The card-state was `no_parts` (correctly: parts hadn't arrived yet) but the column resolved to `plating`, so: + +- The receiver, who watches the Receiving column, never sees the job +- The Masking operator sees a card they can't start +- The parts physically can't move forward because nobody knows they need to be received + +The auto-complete contract-review logic (`_fp_autocomplete_repeat_order_contract_review`) is **NOT the bug** — it correctly marks Contract Review as done when the part has a complete QA-005 history. The real problems are deeper. + +## Root causes + +### Root cause 1 — `ENP-ALUM-BASIC` (id 3620) has DUPLICATE SEQUENCES + +``` +seq 10: Contract Review (id 3853, kind=contract_review) +seq 10: Masking (id 3877, kind=mask) ← TIE +seq 20: Incoming Insp. (id 3854, kind=receiving) +seq 20: Racking (id 3855, kind=racking) ← TIE +seq 40: ENP-Alum Line (id 3859, sub_process, has E-Nickel Plating child) +seq 40: ENP-Alum Line (id 4056, sub_process, empty) ← DUPLICATE +seq 50: De-Masking +seq 60: Oven baking +... +``` + +When this base recipe is cloned per-part by the configurator (`fp.process.node.copy()`), tied sequences resolve by id. So in the clone: + +- Position 10: Contract Review (id 3853 < id 3877 → wins) +- Position 20: Masking (the second one at 10 → promoted to 20) +- Position 30: Incoming Inspection (one of the seq-20 ties → promoted to 30) +- Position 40: Racking (the other seq-20 → promoted to 40) + +After Contract Review auto-completes, the live step is **Masking** (kind=mask, area=masking) — which our prior live-step fix routes to the Masking column, not Receiving. The clone for WO-30057 (recipe 4649) followed exactly this pattern. + +### Root cause 2 — 24 per-part clone recipes accumulated, all carrying the broken ordering + +Each clone is its own `fusion.plating.process.node` row with `node_type='recipe'` and a name like `BASE_NAME — PART_NUMBER Rev X`. There are 24 such clones on entech. Several are referenced by historical jobs (24 cancelled + 7 done jobs use them), but all those jobs are terminal — none are in-flight. + +### Root cause 3 — ~10 nodes across base recipes still have `kind=other` + +Mostly niche names the existing `fp_resolve_step_kind()` resolver doesn't know: + +| Recipe | Node | Currently | Should be | +|---|---|---|---| +| 3645 ENP-STEEL-MP-BASIC | Blasting (If Required) | other | blast | +| 3645 ENP-STEEL-MP-BASIC | Adhesion Test Coupon | other | inspect | +| 3689 ENP-SP | Adhesion Test Coupon | other | inspect | +| 3689 ENP-SP | Adhesion Testing | other | inspect | +| 3689 ENP-SP | Corrosion Testing | other | inspect | +| 3689 ENP-SP | Lab Testing | other | inspect | +| 3945 ENP ALUM BASIC HP SC2 | ENP-Alum Line - HP | other | other (intentional — sub_process) | +| 3782 Chemical Conversion Process | Strip Process - AL | other | wet_process | +| 3782 Chemical Conversion Process | Plug The Threaded Holes | other | mask | +| 3782 Chemical Conversion Process | Chemical Conversion (sub_process) | other | wet_process | +| 3782 Chemical Conversion Process | Trivalent Chromate Conversion (A-14 / A) | other | wet_process | + +### Root cause 4 — Recipe duplication has no kind safety net + +`fp.process.node.copy()` uses the standard Odoo deep-copy which inherits all fields including `kind_id`. So if the source has bad kinds, the clone inherits bad kinds. Even after we fix the base recipes, future authoring mistakes will propagate. + +--- + +## Approved fix + +### Change 1 — Delete all 24 per-part clone recipes + +Identify clones by name pattern (em-dash with spaces — the configurator's separator): `name ILIKE '% — %' AND node_type='recipe'`. + +FK constraints verified: +- `fp.job.recipe_id` → SET NULL (historical job loses recipe ref, step data persists) +- `fp.job.start_at_node_id` → SET NULL +- `fp.job.step.recipe_node_id` → SET NULL +- `fusion.plating.process.node.parent_id` → CASCADE (child nodes auto-deleted) +- `fp.coating.config.recipe_id` → SET NULL +- `fp.pricing.rule.recipe_id` → SET NULL +- `fp.part.catalog.default_process_id` → SET NULL +- Zero rows in the 2 RESTRICT FKs (`fp.quote.configurator.recipe_id`, `fp.job.node.override.node_id`) point at clones → no blockers + +One DELETE statement: + +```sql +DELETE FROM fusion_plating_process_node + WHERE node_type = 'recipe' + AND name ILIKE '% — %'; +``` + +CASCADE handles all child operations + steps + sub_processes via the `parent_id` chain. SET NULL handles all the historical job references. + +### Change 2 — Fix recipe 3620 ENP-ALUM-BASIC + +**a. Resequence operations** so each has a unique sequence and Receiving precedes physical work: + +| New sequence | Operation | id | Was at | +|---|---|---|---| +| 10 | Contract Review | 3853 | 10 | +| 20 | Incoming Inspection (Standard) | 3854 | 20 (tied) | +| 30 | Masking | 3877 | 10 (tied) | +| 40 | Racking | 3855 | 20 (tied) | +| 50 | Ready for processing | 3858 | 30 | +| 60 | ENP-Alum Line | 3859 | 40 (tied) | +| 70 | De-Masking | 3861 | 50 | +| 80 | Oven baking | 3864 | 60 | +| 90 | De-racking | 3867 | 70 | +| 100 | Oven bake (Post de-rack) | 4067 | 80 | +| 110 | Post-plate Inspection | 3873 | 90 | +| 120 | Final Inspection | 3876 | 120 | + +Per the user decision (mask first, then rack — matches the existing De-Masking step's position between Plating and Bake; de-mask before de-rack would be illogical). + +**b. Delete duplicate empty ENP-Alum Line sub_process** (id 4056, no children). The real one (id 3859, contains E-Nickel Plating) survives. + +### Change 3 — Extend `fp_resolve_step_kind()` + +In [`fusion_plating/__init__.py`](../../../fusion_plating/__init__.py): + +**a. Add aliases to `_STARTER_KIND_BY_NAME`:** + +```python +# Blasting variants +'blasting': 'blast', +'bead blast': 'blast', +'bead blasting': 'blast', +'media blast': 'blast', +'media blasting': 'blast', +# Inspection variants the resolver didn't know +'adhesion test coupon': 'inspect', +'adhesion testing': 'inspect', +'corrosion testing': 'inspect', +'lab testing': 'inspect', +# Strip + chemical conversion + plugging (mostly 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', +``` + +**b. Add parenthetical stripping** to `fp_resolve_step_kind()` so `"Incoming Inspection (Standard)"`, `"Blasting (If Required)"`, `"Trivalent Chromate Conversion (A-14 / A)"` etc. resolve through their base name. Strip first, look up second, fall through to the resolver's other rules: + +```python +def fp_resolve_step_kind(name): + if not name: + return None + key = name.strip().lower() + if key in _STARTER_KIND_BY_NAME: + return _STARTER_KIND_BY_NAME[key] + # NEW: strip parenthetical suffixes — "Masking (If Required)" → + # "Masking", "Incoming Inspection (Standard)" → "Incoming + # Inspection". + 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] + if key.startswith('ready for ') or key.startswith('ready '): + return 'gating' + return None +``` + +**c. Translate resolver kinds to active `fp.step.kind.code` values.** Several resolver outputs (`cleaning`, `electroclean`, `etch`, `rinse`, `strike`, `dry`, `wbf_test`) map to kinds that are inactive in the dropdown — those should roll up to the active `wet_process` kind. Add a translation in the migration: + +```python +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', +} +``` + +### Change 4 — Backfill `kind=other` nodes via the extended resolver + +For every `fusion.plating.process.node` where `kind.code='other'` and `name` is set: +- Call `fp_resolve_step_kind(name)` +- Translate via `RESOLVER_KIND_TO_ACTIVE_KIND` +- If a match: look up `fp.step.kind` by code, write `kind_id` +- If no match: leave as-is (admin can pick later) + +Idempotent — only affects nodes currently at `kind=other`. + +### Change 5 — Auto-classify hook on `fusion.plating.process.node` + +In [`fusion_plating/models/fp_process_node.py`](../../../fusion_plating/models/fp_process_node.py), add a post-write helper that runs after `create()` and `write()`: + +```python +def _fp_autoclassify_kind(self): + """If kind_id is 'other' AND name resolves via fp_resolve_step_kind, + upgrade to the resolved active kind. Idempotent — never overrides + a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify=True. + """ + if self.env.context.get('fp_skip_kind_autoclassify'): + return + from odoo.addons.fusion_plating import fp_resolve_step_kind + Kind = self.env['fp.step.kind'] + other = Kind.search([('code', '=', 'other')], limit=1) + if not other: + return + 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 + target = Kind.search([('code', '=', target_code)], limit=1) + if target: + 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) + # Only re-run autoclassify when name OR kind_id changed + if 'name' in vals or 'kind_id' in vals: + self._fp_autoclassify_kind() + return res +``` + +Two side-effects this guarantees: +- Recipe duplication via `copy()` → after super().copy() runs, the hook fires on the new node and upgrades the kind if applicable. So future per-part clones get correct kinds even if the source was sloppy. +- Authors typing a step name in the Simple/Tree editor → kind auto-upgrades as soon as the name is saved (provided they hadn't already picked a specific kind). + +### Change 6 — `no_parts` cards always land in Receiving column + +In [`fusion_plating_shopfloor/controllers/plant_kanban.py:165`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py): + +```python +def _resolve_card_area(job): + """...""" + # NEW — Defect: no_parts cards belong in Receiving regardless of + # active step. The receiver is who acts; the receiver works the + # Receiving column. + if job.card_state == 'no_parts': + return 'receiving' + if job.active_step_id and job.active_step_id.area_kind: + return job.active_step_id.area_kind + return 'receiving' +``` + +Belt-and-suspenders so even if a job slips through with a bad area_kind or before kinds are recomputed, "no parts" cards still show where they belong. + +### Change 7 — Unified migration + +New file: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`. Runs AFTER fusion_plating's data files load (so the resolver extensions are available). + +Phases, in order: + +1. **Resequence recipe 3620** ops + delete duplicate empty `ENP-Alum Line` sub_process (id 4056). +2. **Backfill `kind=other` nodes** using the extended resolver + active-kind translation. Affects ~10 nodes across recipes 3645/3689/3945/3782. +3. **Delete the 24 clone recipes** — single DELETE on `fusion_plating_process_node` where `name ILIKE '% — %' AND node_type='recipe'`. CASCADE cleans up children; SET NULL handles job refs. +4. **Recompute `fp.job.step.area_kind`** on all rows. After the kind-backfill + clone delete, some steps lose their `recipe_node_id` (NULL); those fall to the catch-all `'plating'`. Acceptable — those are all done/cancelled jobs. +5. **Recompute `fp.job.active_step_id` + `card_state`** on in-flight jobs (currently 0 on entech, but defensive). + +All phases idempotent — re-running `-u` is safe. + +### Change 8 — Version bumps + +| Module | From | To | +|---|---|---| +| `fusion_plating` | `19.0.21.2.0` | `19.0.21.3.0` (resolver + autoclassify hook + new aliases) | +| `fusion_plating_jobs` | `19.0.10.25.0` | `19.0.10.26.0` (migration only) | +| `fusion_plating_shopfloor` | `19.0.33.1.3` | `19.0.33.1.4` (no_parts override) | + +--- + +## Out of scope (explicit) + +- **Reordering the other 6 base recipes.** Only recipe 3620 has the documented duplicate-sequence problem. The others have sane sequences and acceptable ordering. +- **Backfilling historical jobs' `area_kind`.** All 31 historical jobs are terminal (cancelled/done). They drop off the live board so their stored area_kind is decorative. +- **Manual kind picks for the ~5 nodes left as `other`** (e.g. `ENP-Alum Line - HP` sub_process). The resolver can't classify them reliably; admin can pick manually if needed. +- **Removing the per-part clone path itself.** The configurator still clones recipes per-part — that's the intended flow. We're just removing existing clones; future SOs will create fresh clones from the fixed base recipes. +- **Battle test for this fix.** The flow (SO confirm → job create → recipe clone → step gen → auto-complete → card-area resolve) is covered by manual smoke. A scripted battle test for this would duplicate significant configurator + auto-complete logic — disproportionate to the fix size. + +--- + +## Test plan + +### Manual smoke (after deploy) + +1. **Confirm clones gone:** + ```sql + SELECT COUNT(*) FROM fusion_plating_process_node + WHERE node_type='recipe' AND name ILIKE '% — %'; + -- expected: 0 + ``` + +2. **Confirm 3620 reordered:** + ```sql + SELECT sequence, name FROM fusion_plating_process_node + WHERE parent_id=3620 ORDER BY sequence; + -- expected: 10=Contract Review, 20=Incoming Inspection, 30=Masking, + -- 40=Racking, 50=Ready for processing, 60=ENP-Alum Line, + -- 70=De-Masking, 80=Oven baking, 90=De-racking, + -- 100=Oven bake (Post de-rack), 110=Post-plate Inspection, + -- 120=Final Inspection + -- NO duplicate sequences. ENP-Alum Line appears ONCE (not twice). + ``` + +3. **Confirm kinds backfilled:** + ```sql + SELECT n.name, k.code FROM fusion_plating_process_node n + JOIN fp_step_kind k ON k.id = n.kind_id + WHERE k.code = 'other' + AND n.node_type IN ('operation','step') + ORDER BY n.name; + -- expected: only ENP-Alum Line - HP (or similar genuinely-other + -- nodes that resolver can't classify) — NOT Adhesion Test + -- Coupon, Corrosion Testing, Lab Testing, Plug The Threaded + -- Holes, etc. + ``` + +4. **End-to-end flow:** + a. Create a new SO with a part whose default recipe is `ENP-ALUM-BASIC`. + b. Confirm the SO. + c. Check: the cloned recipe has Contract Review at sequence 10, Incoming Inspection at sequence 20, Masking at 30, Racking at 40. + d. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' from the no_parts override OR because Incoming Inspection is the active step after Contract Review auto-completes). + e. Mark Incoming Inspection done → card moves to Masking column. + +5. **Auto-classify hook:** + a. Open the Simple Editor on any recipe. + b. Drop a new step, type name "Masking" (don't pick a kind). + c. Save the recipe. + d. Refresh the page. + e. Confirm the kind dropdown shows "Masking" (not "Other"). + +--- + +## Roll-out + +1. Implement Changes 1-8 in one branch. +2. Local dev test — no local container available, so skip; verify directly on entech. +3. Deploy to entech via the standard `pct exec 111` flow. +4. SQL spot-checks per the test plan. +5. Manual smoke (steps 4 + 5). +6. Commit + push. + +--- + +## Files touched + +| File | Change | +|---|---| +| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME`, add parenthetical-strip in `fp_resolve_step_kind()` | +| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + hooks in `create()` and `write()` | +| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` | +| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase migration (Changes 2, 4, 1, recompute, recompute) | +| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` | +| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving override in `_resolve_card_area` | +| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` | + +Estimated diff: ~250 lines added, ~20 modified.