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

@@ -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': """

View File

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

View File

@@ -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 <sub-process>" 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;

View File

@@ -76,6 +76,12 @@
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
<span class="o_fp_step_name" t-esc="step.name"/>
<span class="o_fp_nested_under badge bg-light text-muted ms-1"
t-if="step.nested_under"
t-att-title="'Inside sub-process: ' + step.nested_under">
<i class="fa fa-sitemap me-1"/>
<t t-esc="step.nested_under"/>
</span>
<span class="o_fp_step_has_instructions"
t-if="step.description"
title="Has operator instructions">

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