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

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.7.1.0',
'version': '19.0.7.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

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,

View File

@@ -120,6 +120,11 @@ export class RecipeTreeEditor extends Component {
importRecipeOptions: [],
importRecipeId: null,
importDedupe: true,
// Insertion anchor for the imported nodes. Values:
// "" → append at the end (default)
// "0" → insert at the start
// "<id>" → insert right before that existing top-level child
importInsertBefore: "",
importing: false,
});
@@ -472,11 +477,21 @@ export class RecipeTreeEditor extends Component {
}
if (!this._recipeId) return;
this.state.importing = true;
// Map the UI anchor to the controller's insert_before_id contract:
// "" → null (append at end)
// "0" → 0 (insert at start)
// "<id>" → int (insert before that top-level child)
const rawBefore = this.state.importInsertBefore;
const insertBeforeId =
rawBefore === "" || rawBefore == null
? null
: parseInt(rawBefore, 10);
try {
const result = await rpc("/fp/recipe/node/import_children", {
source_recipe_id: parseInt(this.state.importRecipeId, 10),
target_parent_id: this._recipeId,
dedupe_by_name: !!this.state.importDedupe,
insert_before_id: insertBeforeId,
});
if (result && result.ok) {
this.notification.add(
@@ -485,6 +500,7 @@ export class RecipeTreeEditor extends Component {
);
this.state.importOpen = false;
this.state.importRecipeId = null;
this.state.importInsertBefore = "";
await this.loadTree();
} else {
this.notification.add(result?.error || "Import failed.", { type: "warning" });
@@ -496,6 +512,15 @@ export class RecipeTreeEditor extends Component {
}
}
// Convenience getter for the XML: the list of top-level children
// currently under the open recipe, for the "Insert before" dropdown.
// Falls back to [] on an unloaded tree.
get topLevelChildren() {
const root = this.state.tree;
if (!root || !root.children) return [];
return root.children;
}
// ---- Navigation ---------------------------------------------------------
onBackToList() {

View File

@@ -207,6 +207,17 @@
<option t-att-value="r.id" t-esc="r.name"/>
</t>
</select>
<label class="o_fp_re_import_label">Insert:</label>
<select class="o_fp_re_import_select o_fp_re_import_pos"
t-model="state.importInsertBefore">
<option value="">At the end</option>
<option value="0">At the start</option>
<t t-foreach="topLevelChildren" t-as="c" t-key="c.id">
<option t-att-value="c.id">
Before <t t-esc="c.name"/>
</option>
</t>
</select>
<label class="o_fp_re_import_dedupe">
<input type="checkbox" t-model="state.importDedupe"/>
Skip duplicate names