Files
Odoo-Modules/fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py
gsinghpal e1fedf7231 fix(fusion_plating): wet_process passthrough + per-clone unlink safety
Two follow-up fixes caught during the entech deploy of recipe cleanup:

1. RESOLVER_KIND_TO_ACTIVE_KIND was missing a self-pass entry for
   'wet_process'. The new aliases added in 19.0.21.3.0 (Chemical
   Conversion, Trivalent Chromate Conversion, Strip Process - AL,
   Plug The Threaded Holes via mask) directly return 'wet_process'
   from the resolver — without the passthrough they didn't translate
   to any active kind and stayed as 'other'. Added 'wet_process':
   'wet_process' so the migration's Phase 2 backfill catches them.

2. Migration 19.0.10.26.0 Phase 3 was using batch unlink
   (clone_recipes.unlink()) which tripped a PostgreSQL FK cascade
   ordering bug on entech ("insert or update on parent_id violates
   FK ..." during the CASCADE chain). Rewrote Phase 3 to delete one
   clone at a time with SAVEPOINT per clone — slower but immune to
   the batching bug, and one failed clone doesn't roll back the
   whole transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:08:35 -04:00

235 lines
9.0 KiB
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 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),
)