# 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".)