From 821e768b7e3e596860f81121c2f5f6b775709dba Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 19 May 2026 22:22:54 -0400 Subject: [PATCH] fix(simple-editor): surface operations nested inside sub_process nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../controllers/simple_recipe_controller.py | 52 +++++- .../static/src/scss/simple_recipe_editor.scss | 13 ++ .../static/src/xml/simple_recipe_editor.xml | 6 + .../fusion_plating/tests/__init__.py | 1 + .../tests/test_simple_recipe_flatten.py | 150 ++++++++++++++++++ 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 fusion_plating/fusion_plating/tests/test_simple_recipe_flatten.py diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 2eef8637..ee381efe 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.20.2.0', + 'version': '19.0.20.3.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py index 1fe54f07..408d4eac 100644 --- a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py +++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py @@ -63,12 +63,60 @@ class SimpleRecipeController(http.Controller): def load(self, recipe_id): recipe = request.env['fusion.plating.process.node'].browse(recipe_id) recipe.check_access('read') - steps = recipe.child_ids.sorted('sequence') + # A recipe authored in the Tree Editor can have `sub_process` + # nodes that hold more operations underneath. The flat + # `recipe.child_ids` walk hid those — operators saw a partial + # recipe in the Simple Editor even though the work order + # generated the full list (bug surfaced on ENP-STEEL-BASIC, + # 2026-05-20). + # + # Match the WO generator: depth-first, collect every + # `operation` node, recurse into `recipe` / `sub_process`, + # skip `step` children (they're rendered as instructions + # within their parent operation). Each operation gets a + # `nested_under` label so the UI can tell the operator which + # sub-process the row came from. + flat_ops = self._flatten_recipe_operations(recipe) return { 'recipe': self._recipe_payload(recipe), - 'steps': [self._step_payload(s) for s in steps], + 'steps': [ + dict(self._step_payload(op), nested_under=path) + for op, path in flat_ops + ], } + def _flatten_recipe_operations(self, recipe): + """Walk a recipe tree DFS, return [(operation_node, path_label)]. + + `path_label` is the name of the enclosing `sub_process` if the + operation lives inside one, else empty. Used by the Simple + Editor to render a "(in Steel Line)" hint next to the step. + """ + out = [] + + def _walk(node, path): + if node.node_type == 'operation': + out.append((node, path)) + # Operations don't recurse — child `step` nodes are + # the operation's own instructions, not separate + # editor rows. + return + if node.node_type in ('recipe', 'sub_process'): + # Recipes themselves carry no path label; sub_process + # name becomes the path for nested children. + sub_path = ( + path if node.node_type == 'recipe' + else (f"{path} › {node.name}" if path else node.name) + ) + for child in node.child_ids.sorted('sequence'): + _walk(child, sub_path) + # `step` nodes at the top level are legacy — flat 'step' + # children of a recipe were silently skipped by + # _generate_steps pre-19.0.18.8.0; we mirror that here. + + _walk(recipe, '') + return out + def _recipe_payload(self, recipe): return { 'id': recipe.id, diff --git a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss index 5c55947f..57c5b54d 100644 --- a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss +++ b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss @@ -224,6 +224,19 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex}); padding: .125rem .5rem; border-radius: 999px; } + // Tree-editor-authored recipes can have operations nested inside a + // sub_process; the Simple Editor flattens those into the same list + // but tags them with a small "inside " badge so the + // author isn't confused about where they came from. + .o_fp_nested_under { + font-size: .7rem; + font-weight: 500; + padding: .15rem .45rem; + border-radius: 999px; + background: $fp-se-page; + color: $fp-se-muted; + i { opacity: .7; } + } .o_fp_step_edit, .o_fp_step_remove { background: none; diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml index df2cebe2..77ee3e84 100644 --- a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml +++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml @@ -76,6 +76,12 @@ . + + + + diff --git a/fusion_plating/fusion_plating/tests/__init__.py b/fusion_plating/fusion_plating/tests/__init__.py index 60b0d988..9da38da0 100644 --- a/fusion_plating/fusion_plating/tests/__init__.py +++ b/fusion_plating/fusion_plating/tests/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating/tests/test_simple_recipe_flatten.py b/fusion_plating/fusion_plating/tests/test_simple_recipe_flatten.py new file mode 100644 index 00000000..07a4db44 --- /dev/null +++ b/fusion_plating/fusion_plating/tests/test_simple_recipe_flatten.py @@ -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')