# -*- 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 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 (matches the existing De-Masking step's # position between Plating and Bake; de-mask before de-rack would be # illogical). 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: # Resequence idempotently - only update if sequence differs. 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 autoclassify - we only touch sequence here. 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: # Cache 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 per-part clone recipes # ============================================================ # Identify by name pattern. The configurator names clones # "BASE_NAME - PART_NUMBER Rev X" with an em-dash separator # (U+2014). No base recipe uses em-dash in its name. # # IMPORTANT: delete one-clone-at-a-time with savepoint per clone. # Batch unlink (clone_recipes.unlink()) tripped a PostgreSQL FK # cascade ordering bug on entech (insert-or-update on parent_id # during the cascade chain). Per-clone unlink with intermediate # cleanup avoids that path entirely and lets one bad clone fail # without rolling back the others. clone_recipes = Node.search([ ('node_type', '=', 'recipe'), ('name', 'ilike', '% — %'), ]) if not clone_recipes: _logger.info( '[recipe-cleanup] Phase 3: no clone recipes found ' '(already deleted on a prior run, or none exist)' ) else: _logger.info( '[recipe-cleanup] Phase 3: deleting %s clone recipes one ' 'at a time (per-clone savepoint)', len(clone_recipes), ) deleted = 0 failed = [] for clone in clone_recipes: cid, cname = clone.id, clone.name cr.execute('SAVEPOINT delete_clone') try: clone.unlink() cr.execute('RELEASE SAVEPOINT delete_clone') deleted += 1 except Exception as e: cr.execute('ROLLBACK TO SAVEPOINT delete_clone') failed.append((cid, cname, type(e).__name__, str(e)[:120])) _logger.warning( '[recipe-cleanup] Phase 3: failed to delete ' 'clone %s ("%s"): %s — continuing', cid, cname, type(e).__name__, ) _logger.info( '[recipe-cleanup] Phase 3: deleted %s/%s clones ' '(%s failures retained for manual review)', deleted, len(clone_recipes), len(failed), ) if failed: for cid, cname, errtype, errmsg in failed: _logger.warning( '[recipe-cleanup] Phase 3 leftover: id=%s name=%r ' 'err=%s: %s', cid, cname, errtype, errmsg, ) # ============================================================ # Phase 4 - Recompute area_kind on all fp.job.step rows # ============================================================ 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), )