Three bugs reported on 2026-05-20:
1. RESURRECTION. User deletes a substep in the Simple Editor (e.g.
Soak Clean (S-3) under Cleaner), then on the next -u fusion_plating
the substep comes back. Root cause: the recipe XML lived in the
manifest's `data` list with `noupdate="1"`. Odoo's noupdate=1 only
blocks UPDATE of existing records — when a record's ir.model.data
row is missing, the loader treats it as "not yet created" and
re-creates from XML. Every upgrade resurrected every user-deleted
seed node.
Fix: pull the recipe XML files out of `data` and load them once
via post_init_hook → _seed_starter_recipes_once. Sentinel checks
ir.model.data for each recipe's root xmlid; if present, skip
loading entirely. Result: deletions are permanent across all
future upgrades. Existing entech recipes untouched.
Files affected: fp_recipe_enp_alum_basic, fp_recipe_enp_steel_basic,
fp_recipe_enp_sp, fp_recipe_general_processing, fp_recipe_anodize,
fp_recipe_chem_conversion.
2. PROMOTE / DEMOTE. Simple Editor had no way to turn a substep into
a top-level operation, or to tuck an operation under another as a
substep. Authors had to delete + re-create. New endpoints:
* /fp/simple_recipe/step/promote → flips node_type 'step' →
'operation', re-parents to the recipe (or sub-process) root,
places right after the old parent operation.
* /fp/simple_recipe/step/demote → flips 'operation' → 'step',
re-parents under the preceding operation (or a caller-supplied
target_op_id). Blocks demoting an operation that has its own
children, with a helpful message.
UI: each row in the editor now carries an up-arrow (promote, only
shown on substeps) and a down-arrow (demote, only shown on
operations). Confirmation dialog explains what's about to happen.
3. DRAG SUBSTEPS. Last commit (2142a66b) disabled drag on substep
rows. Operators couldn't reorder substeps within an operation.
Re-enabled drag on substeps. The step_reorder endpoint now groups
incoming node_ids by parent_id and renumbers within each parent
(10, 20, 30…). Cross-parent drag still no-ops on parent change —
Promote/Demote buttons are the way to move between parents.
Drive-by:
- Added `from odoo import _` to the controller (missing import the
new endpoints surfaced).
- Edit-panel field wiring audited: all fields visible in the screen
(Step name, Default instructions, Step Type, Triggers Workflow,
Parallel Start, QA Sign-off, Collect measurements, Instruction
Images, custom prompts) persist correctly through step_write or
dedicated endpoints. No broken wires.
Tests: 15 total in TestSimpleRecipeFlatten (was 10). 5 new cover
promote happy-path, promote reject (non-substep), demote happy-path,
demote block on has_children, and reorder parent-scoping.
Module: fusion_plating 19.0.20.4.0 → 19.0.20.5.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
306 lines
14 KiB
Python
306 lines
14 KiB
Python
# -*- 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_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,
|
||
})
|
||
self.env['fusion.plating.process.node'].create({
|
||
'name': 'Substep 2', 'node_type': 'step',
|
||
'parent_id': self.op_a.id, 'sequence': 20,
|
||
})
|
||
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).
|
||
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_promote_turns_substep_into_operation(self):
|
||
# Add a substep under op_a, promote it, verify it moved.
|
||
sub = self.env['fusion.plating.process.node'].create({
|
||
'name': 'Sub1', 'node_type': 'step',
|
||
'parent_id': self.op_a.id, 'sequence': 10,
|
||
})
|
||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||
import SimpleRecipeController
|
||
from unittest.mock import patch
|
||
class FakeReq:
|
||
env = self.env
|
||
path = ('odoo.addons.fusion_plating.controllers.'
|
||
'simple_recipe_controller.request')
|
||
with patch(path, FakeReq()):
|
||
res = SimpleRecipeController().step_promote(sub.id)
|
||
self.assertTrue(res['ok'])
|
||
sub.invalidate_recordset()
|
||
self.assertEqual(sub.node_type, 'operation')
|
||
self.assertEqual(sub.parent_id.id, self.recipe.id)
|
||
|
||
def test_promote_rejects_non_substep(self):
|
||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||
import SimpleRecipeController
|
||
from unittest.mock import patch
|
||
class FakeReq:
|
||
env = self.env
|
||
path = ('odoo.addons.fusion_plating.controllers.'
|
||
'simple_recipe_controller.request')
|
||
with patch(path, FakeReq()):
|
||
res = SimpleRecipeController().step_promote(self.op_a.id)
|
||
self.assertFalse(res['ok'])
|
||
self.assertEqual(res['error'], 'not_a_substep')
|
||
|
||
def test_demote_turns_operation_into_substep_under_previous(self):
|
||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||
import SimpleRecipeController
|
||
from unittest.mock import patch
|
||
class FakeReq:
|
||
env = self.env
|
||
path = ('odoo.addons.fusion_plating.controllers.'
|
||
'simple_recipe_controller.request')
|
||
# Demote Op D into Sub-X (its preceding operation is op_a at
|
||
# the recipe root, but Sub-X is between them — the preceding
|
||
# OPERATION sibling at the recipe root is op_a).
|
||
with patch(path, FakeReq()):
|
||
res = SimpleRecipeController().step_demote(self.op_d.id)
|
||
self.assertTrue(res['ok'])
|
||
self.op_d.invalidate_recordset()
|
||
self.assertEqual(self.op_d.node_type, 'step')
|
||
# The preceding operation at the recipe root is op_a (Sub-X is
|
||
# not an operation, gets filtered out).
|
||
self.assertEqual(self.op_d.parent_id.id, self.op_a.id)
|
||
|
||
def test_demote_blocks_when_operation_has_children(self):
|
||
# op_a gets a substep — now demoting op_a should fail because
|
||
# it has children.
|
||
self.env['fusion.plating.process.node'].create({
|
||
'name': 'A-child', 'node_type': 'step',
|
||
'parent_id': self.op_a.id, 'sequence': 10,
|
||
})
|
||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||
import SimpleRecipeController
|
||
from unittest.mock import patch
|
||
class FakeReq:
|
||
env = self.env
|
||
path = ('odoo.addons.fusion_plating.controllers.'
|
||
'simple_recipe_controller.request')
|
||
with patch(path, FakeReq()):
|
||
res = SimpleRecipeController().step_demote(self.op_a.id)
|
||
self.assertFalse(res['ok'])
|
||
self.assertEqual(res['error'], 'has_children')
|
||
|
||
def test_reorder_renumbers_per_parent(self):
|
||
# Add two substeps under op_a so reorder has something to swap.
|
||
s1 = self.env['fusion.plating.process.node'].create({
|
||
'name': 's1', 'node_type': 'step',
|
||
'parent_id': self.op_a.id, 'sequence': 10,
|
||
})
|
||
s2 = self.env['fusion.plating.process.node'].create({
|
||
'name': 's2', 'node_type': 'step',
|
||
'parent_id': self.op_a.id, 'sequence': 20,
|
||
})
|
||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||
import SimpleRecipeController
|
||
from unittest.mock import patch
|
||
class FakeReq:
|
||
env = self.env
|
||
path = ('odoo.addons.fusion_plating.controllers.'
|
||
'simple_recipe_controller.request')
|
||
# Send reversed order — s2 should come out at seq=10, s1 at 20.
|
||
with patch(path, FakeReq()):
|
||
SimpleRecipeController().step_reorder([s2.id, s1.id])
|
||
s1.invalidate_recordset()
|
||
s2.invalidate_recordset()
|
||
self.assertEqual(s2.sequence, 10)
|
||
self.assertEqual(s1.sequence, 20)
|
||
|
||
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')
|