diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 83279546..2f1e7b68 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating/controllers/recipe_controller.py b/fusion_plating/fusion_plating/controllers/recipe_controller.py index 27cd1bf9..f0d73d3d 100644 --- a/fusion_plating/fusion_plating/controllers/recipe_controller.py +++ b/fusion_plating/fusion_plating/controllers/recipe_controller.py @@ -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. + * → 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, diff --git a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js index 31fa09d9..fccbb59b 100644 --- a/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js +++ b/fusion_plating/fusion_plating/static/src/js/recipe_tree_editor.js @@ -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 + // "" → 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) + // "" → 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() { diff --git a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml index b8426c1f..770e9422 100644 --- a/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml +++ b/fusion_plating/fusion_plating/static/src/xml/recipe_tree_editor.xml @@ -207,6 +207,17 @@