feat(plating): tree-editor import supports insert-before position

The import feature appended every imported node to the end of the
target recipe. That's wrong for the common case — General Processing
has Shipping as its last operation, so importing an Electroless
Nickel pack should land BEFORE Shipping, not after it. The user
would otherwise have to click Move Up dozens of times.

Controller: /fp/recipe/node/import_children now accepts
insert_before_id:
  null/missing  → append at end (default, unchanged)
  0             → insert at the start
  <positive id> → insert right before that top-level child

Implementation reorders target's top-level children in one pass
after Phase 1 creates the copies (placeholder sequence=0). Phase 2
splits existing vs. new, finds the anchor index in the existing
list, and reassigns sequences 10/20/30/... across the merged list.
Collisions on the old max_seq-based append strategy are eliminated.

JS: state.importInsertBefore drives a new "Insert:" dropdown in the
toolbar with options:
  — At the end — (default)
  — At the start —
  Before <each top-level child name>

Smoke on entech (3-case): insert-before-middle, insert-at-start,
insert-at-end all produce the expected ordering.

fusion_plating → 19.0.7.2.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-23 08:16:05 -04:00
parent 03f41422de
commit 9d7b7daf5a
4 changed files with 92 additions and 15 deletions

View File

@@ -203,13 +203,24 @@ class FpRecipeController(http.Controller):
# ------------------------------------------------------------------
@http.route('/fp/recipe/node/import_children', type='jsonrpc', auth='user')
def import_children(self, source_recipe_id, target_parent_id,
dedupe_by_name=True):
dedupe_by_name=True, insert_before_id=None):
"""Copy every top-level child of `source_recipe_id` under
`target_parent_id`, preserving the sub-tree structure.
dedupe_by_name: when True, skip any immediate child whose name
already exists under target_parent_id. Useful for importing a
preset without clobbering custom tweaks already made.
Args:
source_recipe_id: recipe to copy children from.
target_parent_id: parent to drop the copied sub-trees under.
dedupe_by_name: when True, skip any immediate child whose name
already exists under target_parent_id. Useful for importing
a preset without clobbering custom tweaks already made.
insert_before_id: controls WHERE the imported nodes land in
the target's top-level ordering.
* None / missing → append to the end (default).
* 0 → insert at the start.
* <positive int> → insert right before that child id.
Needed because "General Processing" has Shipping as the
LAST operation — importing a plating pack belongs between
Scheduling and Shipping, not after Shipping.
Returns: {ok, imported_count, skipped_count}
"""
@@ -230,7 +241,6 @@ class FpRecipeController(http.Controller):
imported = 0
skipped = 0
max_seq = max((c.sequence for c in target.child_ids), default=0)
def _copy_subtree(src_node, new_parent, base_seq):
"""Deep-copy src_node under new_parent, recursing children.
@@ -240,12 +250,6 @@ class FpRecipeController(http.Controller):
* strip child_ids (we recurse ourselves)
* strip parent_path (Odoo recomputes from parent_id)
* force parent_id + sequence to our target values
The previous copy()-based version sometimes produced a
flattened tree because copy() on a _parent_store model can
leave parent_id pointed at the original source when the
override in copy_vals collides with the field's copy= flag.
copy_data() returns a plain dict — safer.
"""
[vals] = src_node.copy_data()
vals.pop('child_ids', None)
@@ -257,17 +261,54 @@ class FpRecipeController(http.Controller):
_copy_subtree(child, new_node, i * 10)
return new_node
for i, child in enumerate(source.child_ids.sorted('sequence'), 1):
# Phase 1 — create every copied top-level child, tracking their
# ids so we can separate them from the original children when
# reordering below.
new_top_level_ids = []
for child in source.child_ids.sorted('sequence'):
key = (child.name or '').strip().lower()
if dedupe_by_name and key and key in existing_names:
skipped += 1
continue
max_seq += 10
_copy_subtree(child, target, max_seq)
# Placeholder sequence; Phase 2 reassigns all top-level seqs.
new_node = _copy_subtree(child, target, 0)
new_top_level_ids.append(new_node.id)
imported += 1
if key:
existing_names.add(key)
# Phase 2 — compute the final top-level ordering and reassign
# sequences so imported nodes land at the requested position
# instead of always appearing after every existing child.
target.invalidate_recordset(['child_ids'])
all_top = list(target.child_ids.sorted('sequence'))
existing_top = [c for c in all_top if c.id not in new_top_level_ids]
new_nodes = [c for c in all_top if c.id in new_top_level_ids]
# Resolve the insertion anchor in the EXISTING list.
anchor_idx = len(existing_top) # default: at the end
if insert_before_id is not None:
try:
before_id = int(insert_before_id)
except (TypeError, ValueError):
before_id = None
if before_id == 0:
anchor_idx = 0
elif before_id:
for idx, node in enumerate(existing_top):
if node.id == before_id:
anchor_idx = idx
break
final_order = (
existing_top[:anchor_idx]
+ new_nodes
+ existing_top[anchor_idx:]
)
for i, node in enumerate(final_order, 1):
if node.sequence != i * 10:
node.sequence = i * 10
return {
'ok': True,
'imported_count': imported,