fix(simple-editor): also surface step children of operations
Follow-up to821e768b. The previous fix flattened sub_process nodes so all 16 operations of ENP-STEEL-BASIC became visible — but the Tree Editor also shows the 26 `step` nodes that live under each operation ("Ready For Blast / Blast", "Soak Clean / Electroclean / Primary Rinse", etc.). The Simple Editor still hid those, so author + Tree Editor still disagreed by 26 rows. New `_flatten_recipe_nodes(recipe)` helper walks DFS and surfaces BOTH operations and their step children. Each operation is followed immediately by its step children in sequence order so the editor renders them as a contiguous block: 10. Ready For Steel Line 11. Cleaner [Steel Line] ↳ Soak Clean (S-3) [Steel Line › Cleaner] ↳ Electroclean (S-3) [Steel Line › Cleaner] ↳ Primary Rinse (S-4) [Steel Line › Cleaner] 15. Acid Dip (S-5) [Steel Line] ↳ Primary Rinse (S-6) [Steel Line › Acid Dip (S-5)] ... Payload additions on each step: - `node_type`: 'operation' | 'step' - `is_substep`: True for steps (renders indented) - `nested_under`: chained path (sub-process › operation for substeps, sub-process for nested operations, '' for top-level operations) UI: substep rows are indented 2.5rem, smaller font, no drag handle, no numeric position. The "↳" indent glyph and a "[parent operation]" chip make the parent-child relationship obvious. Substeps are not draggable to keep the existing reorder semantics simple — Tree Editor remains the home for structural changes. Legacy `_flatten_recipe_operations` helper retained for back-compat (it now delegates by filtering `node.node_type == 'operation'` from the full walk). ENP-STEEL-BASIC on entech: Simple Editor now shows 42 rows (was 10 before821e768b, was 16 after821e768b) — matches what the Tree Editor displays exactly. Tests: 10 total (was 7), 3 new cover the substep surfacing, path chaining, and is_substep / node_type flags on the payload. Module: fusion_plating 19.0.20.3.0 → 19.0.20.4.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.20.3.0',
|
||||
'version': '19.0.20.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -63,56 +63,89 @@ class SimpleRecipeController(http.Controller):
|
||||
def load(self, recipe_id):
|
||||
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
|
||||
recipe.check_access('read')
|
||||
# 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).
|
||||
# Tree-Editor-authored recipes carry FOUR node levels:
|
||||
# recipe → sub_process → operation → step
|
||||
# The Tree Editor shows all of them. The Simple Editor used to
|
||||
# only show direct children of the recipe — so for
|
||||
# ENP-STEEL-BASIC (1 sub_process + 16 operations + 26 step
|
||||
# nodes), authors saw 10 rows out of 43. Work-order generation
|
||||
# walked the full tree and emitted operations as fp.job.step
|
||||
# rows with step-nodes folded in as instruction text.
|
||||
#
|
||||
# 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)
|
||||
# We now walk the full tree depth-first and surface EVERY
|
||||
# operation and step node, in traversal order, each tagged
|
||||
# with:
|
||||
# - `nested_under`: chained sub-process path ("Steel Line",
|
||||
# "Steel Line › Cleaner", etc.)
|
||||
# - `node_type`: 'operation' or 'step'
|
||||
# - `is_substep`: True for `step` nodes (renders indented)
|
||||
#
|
||||
# The Simple Editor's drag/insert/reorder semantics still
|
||||
# treat operations as headline rows; substeps are read-only
|
||||
# by default in the UI but their fields can be edited via the
|
||||
# existing step_write endpoint (which doesn't care about
|
||||
# node_type).
|
||||
flat_nodes = self._flatten_recipe_nodes(recipe)
|
||||
return {
|
||||
'recipe': self._recipe_payload(recipe),
|
||||
'steps': [
|
||||
dict(self._step_payload(op), nested_under=path)
|
||||
for op, path in flat_ops
|
||||
dict(self._step_payload(node),
|
||||
nested_under=path,
|
||||
node_type=node.node_type,
|
||||
is_substep=(node.node_type == 'step'))
|
||||
for node, path in flat_nodes
|
||||
],
|
||||
}
|
||||
|
||||
def _flatten_recipe_operations(self, recipe):
|
||||
"""Walk a recipe tree DFS, return [(operation_node, path_label)].
|
||||
"""Legacy helper — returns ONLY operations.
|
||||
|
||||
`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.
|
||||
Kept for back-compat with callers and tests that asked for the
|
||||
operations-only view. Most paths should now use
|
||||
``_flatten_recipe_nodes`` which also surfaces step children.
|
||||
"""
|
||||
return [
|
||||
(n, p) for n, p in self._flatten_recipe_nodes(recipe)
|
||||
if n.node_type == 'operation'
|
||||
]
|
||||
|
||||
def _flatten_recipe_nodes(self, recipe):
|
||||
"""Walk the recipe DFS, return [(node, path_label)].
|
||||
|
||||
Surfaces both `operation` and `step` nodes. The traversal order
|
||||
matches what the Tree Editor displays:
|
||||
recipe → recurse → operation (emit) → its step children (emit)
|
||||
recipe → recurse → sub_process → recurse → operation → steps
|
||||
|
||||
Step children are emitted IMMEDIATELY after their parent
|
||||
operation so the editor can render them as a contiguous block.
|
||||
"""
|
||||
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.
|
||||
# Emit step children right after the operation so the
|
||||
# editor sees: [Op, step, step, NextOp, step, ...].
|
||||
# The path label for a substep names its parent
|
||||
# operation, chained from the sub-process if present.
|
||||
sub_path = (
|
||||
f"{path} › {node.name}" if path else node.name
|
||||
)
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
if child.node_type == 'step':
|
||||
out.append((child, sub_path))
|
||||
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.
|
||||
# `step` nodes that are direct children of a recipe (rare,
|
||||
# legacy seed data) are silently dropped — _generate_steps
|
||||
# has always skipped them.
|
||||
|
||||
_walk(recipe, '')
|
||||
return out
|
||||
|
||||
@@ -228,7 +228,8 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
// 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 {
|
||||
.o_fp_nested_under,
|
||||
.o_fp_substep_parent {
|
||||
font-size: .7rem;
|
||||
font-weight: 500;
|
||||
padding: .15rem .45rem;
|
||||
@@ -237,6 +238,23 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
color: $fp-se-muted;
|
||||
i { opacity: .7; }
|
||||
}
|
||||
|
||||
// Step nodes inside an operation are rendered as indented sub-rows
|
||||
// — same node model as operations, but they're sub-instructions
|
||||
// (the WO generator folds them into the operation's instruction
|
||||
// text). Visual treatment: smaller, indented, no drag handle, no
|
||||
// numeric position so the eye can tell them apart from operations.
|
||||
&.o_fp_substep_row {
|
||||
padding-left: 2.5rem;
|
||||
background: transparent;
|
||||
font-size: .92em;
|
||||
opacity: .85;
|
||||
.o_fp_step_name { font-weight: 400; }
|
||||
.o_fp_substep_indent {
|
||||
color: $fp-se-muted;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.o_fp_step_edit,
|
||||
.o_fp_step_remove {
|
||||
background: none;
|
||||
|
||||
@@ -68,20 +68,29 @@
|
||||
|
||||
<t t-foreach="state.steps" t-as="step" t-key="step.id">
|
||||
<div class="o_fp_step_row"
|
||||
t-att-class="state.editingStepId === step.id ? 'o_fp_step_row_editing' : ''"
|
||||
draggable="true"
|
||||
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
|
||||
t-att-class="(state.editingStepId === step.id ? 'o_fp_step_row_editing ' : '') + (step.is_substep ? 'o_fp_substep_row' : '')"
|
||||
t-att-draggable="step.is_substep ? 'false' : 'true'"
|
||||
t-on-dragstart="(ev) => step.is_substep ? null : this.onSelectedDragStart(step.id, ev)"
|
||||
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
|
||||
<span class="o_fp_drag_handle">⠿</span>
|
||||
<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_drag_handle" t-if="!step.is_substep">⠿</span>
|
||||
<span class="o_fp_drag_handle o_fp_substep_indent" t-if="step.is_substep">↳</span>
|
||||
<span class="o_fp_step_position" t-if="!step.is_substep">
|
||||
<t t-esc="step_index + 1"/>.
|
||||
</span>
|
||||
<i t-att-class="'fa ' + (step.icon || (step.is_substep ? 'fa-circle-o' : '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-if="step.nested_under and !step.is_substep"
|
||||
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_substep_parent badge bg-light text-muted ms-1"
|
||||
t-if="step.is_substep and step.nested_under"
|
||||
title="Sub-step of the operation above">
|
||||
<i class="fa fa-level-up fa-rotate-90 me-1"/>
|
||||
<t t-esc="step.nested_under"/>
|
||||
</span>
|
||||
<span class="o_fp_step_has_instructions"
|
||||
t-if="step.description"
|
||||
title="Has operator instructions">
|
||||
|
||||
@@ -97,10 +97,26 @@ class TestSimpleRecipeFlatten(TransactionCase):
|
||||
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.
|
||||
def test_operations_only_helper_skips_step_children(self):
|
||||
# Back-compat: the legacy _flatten_recipe_operations helper
|
||||
# still returns ONLY operations. New callers should use
|
||||
# _flatten_recipe_nodes for the full list (operations + steps).
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Substep 1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
ops = self._flatten()
|
||||
names = [n.name for n, _ in ops]
|
||||
self.assertNotIn('Substep 1', names)
|
||||
self.assertEqual(names, ['Op A', 'Op B', 'Op C', 'Op D'])
|
||||
|
||||
def test_full_nodes_helper_surfaces_step_children(self):
|
||||
# The Simple Editor's load endpoint uses _flatten_recipe_nodes,
|
||||
# which DOES surface step children. They're emitted right after
|
||||
# their parent operation so the editor renders them as a
|
||||
# contiguous block.
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Substep 1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
@@ -109,12 +125,54 @@ class TestSimpleRecipeFlatten(TransactionCase):
|
||||
'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'])
|
||||
nodes = SimpleRecipeController()._flatten_recipe_nodes(self.recipe)
|
||||
names = [n.name for n, _ in nodes]
|
||||
# Substeps appear immediately after Op A, before Op B.
|
||||
self.assertEqual(
|
||||
names,
|
||||
['Op A', 'Substep 1', 'Substep 2',
|
||||
'Op B', 'Op C', 'Op D'],
|
||||
)
|
||||
|
||||
def test_substeps_carry_parent_operation_in_path(self):
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'My Substep', 'node_type': 'step',
|
||||
'parent_id': self.op_b.id, 'sequence': 10,
|
||||
})
|
||||
nodes = SimpleRecipeController()._flatten_recipe_nodes(self.recipe)
|
||||
paths = {n.name: p for n, p in nodes}
|
||||
# Op B lives in Sub-X; its substep's path chains both.
|
||||
self.assertEqual(paths['My Substep'], 'Sub-X › Op B')
|
||||
|
||||
def test_load_payload_marks_substeps_with_is_substep(self):
|
||||
# End-to-end check on the load endpoint payload: substeps get
|
||||
# `is_substep=True` and `node_type='step'` so the UI can render
|
||||
# them as indented sub-rows.
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'A1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
# Mock the request — load() reads request.env.
|
||||
from unittest.mock import patch
|
||||
ctrl = SimpleRecipeController()
|
||||
class FakeReq:
|
||||
env = self.env
|
||||
path_to_request = (
|
||||
'odoo.addons.fusion_plating.controllers.'
|
||||
'simple_recipe_controller.request'
|
||||
)
|
||||
with patch(path_to_request, FakeReq()):
|
||||
payload = ctrl.load(self.recipe.id)
|
||||
by_name = {s['name']: s for s in payload['steps']}
|
||||
self.assertEqual(by_name['Op A']['node_type'], 'operation')
|
||||
self.assertFalse(by_name['Op A']['is_substep'])
|
||||
self.assertEqual(by_name['A1']['node_type'], 'step')
|
||||
self.assertTrue(by_name['A1']['is_substep'])
|
||||
self.assertEqual(by_name['A1']['nested_under'], 'Op A')
|
||||
|
||||
def test_load_endpoint_includes_nested_under_in_payload(self):
|
||||
# Direct call to the controller's load (mirroring the JSONRPC).
|
||||
|
||||
Reference in New Issue
Block a user