fix(simple-editor): surface operations nested inside sub_process nodes
Bug on ENP-STEEL-BASIC (2026-05-20): authoring used the Tree Editor to build a recipe with a "Steel Line" sub_process holding 7 nested operations (Cleaner, Acid Dip, Nickel Strike, E-Nickel Plate, etc.). The Simple Editor's /fp/simple_recipe/load endpoint only walked `recipe.child_ids`, so it returned 10 steps. The work order generator (fp.job._generate_steps) walked the same tree depth-first and emitted 16 steps. Author and operator disagreed about what was in the recipe. Fix: new `_flatten_recipe_operations(recipe)` helper walks the tree depth-first, recurses into `recipe` and `sub_process`, emits each `operation` exactly once, skips `step` children (they're sub- instructions of operations). Mirrors the WO walker. Step payload now carries a `nested_under` string — the chained sub- process name(s) the operation lives inside (empty for top-level). The Simple Editor XML renders that as a small "↳ Steel Line" badge next to the step name so the author can see where each row came from in the tree. Deep nesting chains with ' › ' (e.g. "Outer › Inner"). `step` children of `recipe` itself remain invisible — they were silently skipped by the WO generator pre-19.0.18.8.0 anyway (only operation nodes spawn fp.job.step rows). Restoring them here would contradict that long-standing contract. Edit/insert/reorder/remove endpoints unchanged: editing a nested operation's name / description / tanks works (no parent change). Drag-reorder within sub-process siblings still works. Drag across sub-process boundaries isn't supported — opens the door for a Tree Editor follow-up if needed, but the immediate "I can't see my steps" complaint is resolved. ENP-STEEL-BASIC on entech now shows all 16 operations in the Simple Editor (was 10), with the 7 inside Steel Line tagged accordingly. Tests: 7 new (TestSimpleRecipeFlatten) — flat recipes still work, nested operations surface with correct path label, sub_process nodes never appear as editor rows, step children of operations stay hidden, deep-nested sub_processes chain path labels. Module: fusion_plating 19.0.20.2.0 → 19.0.20.3.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
from . import test_fp_work_centre
|
||||
from . import test_fp_job_state_machine
|
||||
from . import test_fp_job_step_state_machine
|
||||
from . import test_simple_recipe_flatten
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Bug surfaced 2026-05-20 on ENP-STEEL-BASIC: a recipe authored in the
|
||||
# Tree Editor has `sub_process` nodes holding more operations
|
||||
# underneath. The Simple Editor used to walk `recipe.child_ids` only,
|
||||
# silently hiding any operation nested inside a sub_process. The work
|
||||
# order generator on the same recipe DID see them, so author + operator
|
||||
# disagreed about what was in the recipe. This test pins the new
|
||||
# depth-first flattening behaviour.
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestSimpleRecipeFlatten(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
Node = cls.env['fusion.plating.process.node']
|
||||
# Tree shape:
|
||||
# Recipe
|
||||
# ├── Op A (top-level)
|
||||
# ├── Sub-process X
|
||||
# │ ├── Op B (nested under X)
|
||||
# │ └── Op C (nested under X)
|
||||
# └── Op D (top-level, after sub-process)
|
||||
cls.recipe = Node.create({
|
||||
'name': 'Test Tree Recipe',
|
||||
'node_type': 'recipe',
|
||||
'sequence': 10,
|
||||
})
|
||||
cls.op_a = Node.create({
|
||||
'name': 'Op A', 'node_type': 'operation',
|
||||
'parent_id': cls.recipe.id, 'sequence': 10,
|
||||
})
|
||||
cls.sub_x = Node.create({
|
||||
'name': 'Sub-X', 'node_type': 'sub_process',
|
||||
'parent_id': cls.recipe.id, 'sequence': 20,
|
||||
})
|
||||
cls.op_b = Node.create({
|
||||
'name': 'Op B', 'node_type': 'operation',
|
||||
'parent_id': cls.sub_x.id, 'sequence': 10,
|
||||
})
|
||||
cls.op_c = Node.create({
|
||||
'name': 'Op C', 'node_type': 'operation',
|
||||
'parent_id': cls.sub_x.id, 'sequence': 20,
|
||||
})
|
||||
cls.op_d = Node.create({
|
||||
'name': 'Op D', 'node_type': 'operation',
|
||||
'parent_id': cls.recipe.id, 'sequence': 30,
|
||||
})
|
||||
|
||||
def _flatten(self):
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
ctrl = SimpleRecipeController()
|
||||
return ctrl._flatten_recipe_operations(self.recipe)
|
||||
|
||||
def test_flat_recipe_returns_top_level_only(self):
|
||||
# Sanity: a flat recipe (no sub-processes) returns its direct
|
||||
# operation children with empty path labels.
|
||||
flat = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Flat', 'node_type': 'recipe', 'sequence': 1,
|
||||
})
|
||||
for name in ('A', 'B', 'C'):
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': name, 'node_type': 'operation',
|
||||
'parent_id': flat.id, 'sequence': 10,
|
||||
})
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
ops = SimpleRecipeController()._flatten_recipe_operations(flat)
|
||||
self.assertEqual([n.name for n, _ in ops], ['A', 'B', 'C'])
|
||||
self.assertEqual([p for _, p in ops], ['', '', ''])
|
||||
|
||||
def test_nested_operations_surface_with_path(self):
|
||||
ops = self._flatten()
|
||||
names = [n.name for n, _ in ops]
|
||||
# Op B / Op C live INSIDE Sub-X — the old load returned 3 ops
|
||||
# (Op A, Op D, plus Sub-X itself); the new one returns 4
|
||||
# operations and skips the sub_process node.
|
||||
self.assertEqual(names, ['Op A', 'Op B', 'Op C', 'Op D'])
|
||||
|
||||
def test_nested_under_label_carries_sub_process_name(self):
|
||||
ops = self._flatten()
|
||||
paths = {n.name: p for n, p in ops}
|
||||
self.assertEqual(paths['Op A'], '')
|
||||
self.assertEqual(paths['Op B'], 'Sub-X')
|
||||
self.assertEqual(paths['Op C'], 'Sub-X')
|
||||
self.assertEqual(paths['Op D'], '')
|
||||
|
||||
def test_sub_process_itself_is_not_surfaced(self):
|
||||
ops = self._flatten()
|
||||
node_types = {n.node_type for n, _ in ops}
|
||||
self.assertEqual(node_types, {'operation'})
|
||||
# Recipe + sub_process never appear as Simple Editor rows.
|
||||
|
||||
def test_step_children_of_operations_are_not_surfaced(self):
|
||||
# Operations carry `step` children as their internal
|
||||
# instructions. The flat list must NOT emit them as separate
|
||||
# rows.
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Substep 1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Substep 2', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 20,
|
||||
})
|
||||
ops = self._flatten()
|
||||
names = [n.name for n, _ in ops]
|
||||
self.assertNotIn('Substep 1', names)
|
||||
self.assertNotIn('Substep 2', names)
|
||||
# The original 4 operations are still there.
|
||||
self.assertEqual(names, ['Op A', 'Op B', 'Op C', 'Op D'])
|
||||
|
||||
def test_load_endpoint_includes_nested_under_in_payload(self):
|
||||
# Direct call to the controller's load (mirroring the JSONRPC).
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
# The endpoint uses request.env; mock by patching the controller's
|
||||
# internal helper to use self.env instead. The flat helper is the
|
||||
# piece worth pinning here; integration with HTTP layer is
|
||||
# exercised live on entech.
|
||||
ctrl = SimpleRecipeController()
|
||||
flat = ctrl._flatten_recipe_operations(self.recipe)
|
||||
names_with_path = [(n.name, p) for n, p in flat]
|
||||
self.assertIn(('Op B', 'Sub-X'), names_with_path)
|
||||
self.assertIn(('Op A', ''), names_with_path)
|
||||
|
||||
def test_deeply_nested_sub_processes_chain_path_labels(self):
|
||||
# Three levels: recipe → Sub-Outer → Sub-Inner → Op-Deep
|
||||
outer = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Sub-Outer', 'node_type': 'sub_process',
|
||||
'parent_id': self.recipe.id, 'sequence': 40,
|
||||
})
|
||||
inner = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Sub-Inner', 'node_type': 'sub_process',
|
||||
'parent_id': outer.id, 'sequence': 10,
|
||||
})
|
||||
op_deep = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Op-Deep', 'node_type': 'operation',
|
||||
'parent_id': inner.id, 'sequence': 10,
|
||||
})
|
||||
ops = self._flatten()
|
||||
deep_paths = {n.name: p for n, p in ops if n.name == 'Op-Deep'}
|
||||
# Path chains the parent labels with ' › '
|
||||
self.assertEqual(deep_paths['Op-Deep'], 'Sub-Outer › Sub-Inner')
|
||||
Reference in New Issue
Block a user