Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
gsinghpal d4e95dcd47 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>
2026-05-24 18:08:46 -04:00

17 KiB

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:

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:

a. Add aliases to _STARTER_KIND_BY_NAME:

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

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:

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, add a post-write helper that runs after create() and write():

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:

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:

    SELECT COUNT(*) FROM fusion_plating_process_node
     WHERE node_type='recipe' AND name ILIKE '% — %';
    -- expected: 0
    
  2. Confirm 3620 reordered:

    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:

    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.