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:
gsinghpal
2026-05-19 22:22:54 -04:00
parent 2645db40a2
commit 821e768b7e
6 changed files with 221 additions and 3 deletions

View File

@@ -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

View File

@@ -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')