From 9a2975b154bb653bd74ed5c348377cd1e6b12ccf Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 17:59:33 -0400 Subject: [PATCH] feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix Migration 19.0.10.26.0/post-migrate.py runs in 5 phases: 1. Resequence recipe 3620 ENP-ALUM-BASIC ops to fix the duplicate- sequence bug (Contract Review=10, Incoming Inspection=20, Masking=30, Racking=40, then the rest). Also delete the empty duplicate ENP-Alum Line sub_process (id 4056). 2. Backfill kind on all kind=other nodes via the extended resolver from fusion_plating 19.0.21.3.0 3. Delete all per-part clone recipes (name contains em-dash) 4. Recompute fp.job.step.area_kind on all steps 5. Recompute fp.job.active_step_id + card_state on in-flight jobs Plant kanban: no_parts cards now always land in the Receiving column regardless of active_step area_kind. The receiver works Receiving; that's where the card belongs when parts haven't arrived. Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating_jobs/__manifest__.py | 2 +- .../migrations/19.0.10.26.0/post-migrate.py | 209 ++++++++++++++++++ .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../controllers/plant_kanban.py | 21 +- 4 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index c39268ad..003b4f53 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.10.25.0', + 'version': '19.0.10.26.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py b/fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py new file mode 100644 index 00000000..622821a1 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py @@ -0,0 +1,209 @@ +# -*- 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. + clone_recipes = Node.search([ + ('node_type', '=', 'recipe'), + ('name', 'ilike', '% — %'), + ]) + if clone_recipes: + clone_names = [c.name for c in clone_recipes] + _logger.info( + '[recipe-cleanup] Phase 3: deleting %s clone recipes: %s', + len(clone_recipes), + ', '.join(clone_names[:10]) + + (' ...' if len(clone_names) > 10 else ''), + ) + clone_recipes.unlink() + _logger.info( + '[recipe-cleanup] Phase 3: deleted %s clone recipes ' + '(CASCADE removed their child nodes; FK SET NULL applied ' + 'to historical fp.job + fp.job.step references)', + len(clone_recipes), + ) + else: + _logger.info( + '[recipe-cleanup] Phase 3: no clone recipes found ' + '(already deleted on a prior run, or none exist)' + ) + + # ============================================================ + # 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), + ) diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index cd593a1b..b852bac6 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.33.1.3', + 'version': '19.0.33.1.4', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py b/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py index 76d1ec98..52a12863 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py @@ -165,15 +165,22 @@ def fields_today_ts(): def _resolve_card_area(job): """Pick the column a card lives in. - Active-step area_kind wins. With the live-step priority chain - (see fp.job._compute_active_step_id), active_step_id is False only - when the job has NO steps at all (recipe not assigned) OR every - step is `done`. Done jobs are filtered off the board upstream - (state-domain in plant_kanban), so this fallback fires only for - truly orphaned cards. + Active-step area_kind wins, EXCEPT for no_parts cards which always + land in Receiving regardless of active step — the receiver is who + needs to act, and they work the Receiving column. With the + live-step priority chain (see fp.job._compute_active_step_id), + active_step_id is False only when the job has NO steps at all + (recipe not assigned) OR every step is `done`. Done jobs are + filtered off the board upstream (state-domain in plant_kanban), + so the orphan fallback fires only for truly orphaned cards. - See spec 2026-05-24-shopfloor-live-step-fix-design.md Change 4. + See specs 2026-05-24-shopfloor-live-step-fix-design.md Change 4 + and 2026-05-24-recipe-cleanup-design.md Change 6. """ + # no_parts cards belong in Receiving regardless of where the + # active step is — the receiver is who acts. + 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 # Orphan fallback — represents a data integrity issue, not a