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