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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user