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.