docs(plating): spec + plan for recipe cleanup + receiving enforcement

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 18:08:46 -04:00
parent e1fedf7231
commit d4e95dcd47
2 changed files with 1168 additions and 0 deletions

View File

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