diff --git a/fusion_plating/fusion_plating/__init__.py b/fusion_plating/fusion_plating/__init__.py index 3982819e..aaaee1d7 100644 --- a/fusion_plating/fusion_plating/__init__.py +++ b/fusion_plating/fusion_plating/__init__.py @@ -30,6 +30,79 @@ def post_init_hook(env): _backfill_contract_review_template(env) _seed_rack_tags_if_empty(env) _migrate_legacy_uom_columns(env) + _seed_starter_recipes_once(env) + + +def _seed_starter_recipes_once(env): + """Load starter recipe XML files on FIRST install only. + + Before 19.0.20.5.0 the recipe XML files (ENP-STEEL-BASIC, ENP-SP, + ENP-ALUM-BASIC, etc.) lived in the manifest's ``data`` list. With + ``noupdate="1"`` we expected user edits / deletions to survive + module upgrades — but Odoo only treats noupdate=1 as "don't update + existing records". If a record's ir.model.data row is deleted via + unlink, Odoo on the next ``-u`` sees the xmlid as missing and + RE-CREATES the record from XML. Bug reported 2026-05-20: every + time the user deleted a substep from a starter recipe, the next + upgrade brought it back. + + Fix: pull those files out of the manifest's data list, load them + here via convert_file ONCE per xmlid. Each file gets a sentinel + check (does the root recipe's xmlid exist in ir.model.data?); if + yes, skip. The hook is itself idempotent so it's safe to run on + every upgrade as well — but the sentinel ensures recipe content + is only seeded the very first time. + """ + from odoo.tools import convert + Module = env['ir.module.module'] + mod = Module.search([('name', '=', 'fusion_plating')], limit=1) + if not mod: + return + + # (xmlid_to_check, data_file_path) pairs. + # If the xmlid already exists in ir.model.data, the file is skipped. + sentinels = [ + ('fusion_plating.recipe_enp_alum_basic', + 'data/fp_recipe_enp_alum_basic.xml'), + ('fusion_plating.recipe_enp_steel_basic', + 'data/fp_recipe_enp_steel_basic.xml'), + ('fusion_plating.recipe_enp_sp', + 'data/fp_recipe_enp_sp.xml'), + ('fusion_plating.recipe_general_processing', + 'data/fp_recipe_general_processing.xml'), + ('fusion_plating.recipe_anodize', + 'data/fp_recipe_anodize.xml'), + ('fusion_plating.recipe_chem_conversion', + 'data/fp_recipe_chem_conversion.xml'), + ] + IMD = env['ir.model.data'] + for xmlid, filepath in sentinels: + module_name, name = xmlid.split('.', 1) + if IMD.search_count([('module', '=', module_name), ('name', '=', name)]): + # Recipe already in DB (either from a previous install, or + # already loaded by an earlier hook run). Don't touch — user + # may have made edits. + continue + # File not yet loaded for this DB. Run it once. + try: + with open_module_data_file(filepath) as fh: + convert.convert_file( + env, module_name, filepath, idref={}, mode='init', + noupdate=True, + ) + _logger.info('Seeded starter recipe %s', xmlid) + except FileNotFoundError: + _logger.warning('Starter recipe file %s not found, skipping', + filepath) + except Exception as exc: + _logger.warning('Could not seed %s: %s', xmlid, exc) + + +def open_module_data_file(relpath): + """Open a file relative to the fusion_plating module root.""" + import os + here = os.path.dirname(__file__) + return open(os.path.join(here, relpath), 'rb') def _resolve_kind_id(env, code): diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 64238a81..7c8edac0 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.20.4.0', + 'version': '19.0.20.5.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -120,12 +120,19 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_jobs_menu.xml', 'data/fp_work_role_data.xml', 'views/fp_work_role_views.xml', - 'data/fp_recipe_enp_alum_basic.xml', - 'data/fp_recipe_enp_steel_basic.xml', - 'data/fp_recipe_enp_sp.xml', - 'data/fp_recipe_general_processing.xml', - 'data/fp_recipe_anodize.xml', - 'data/fp_recipe_chem_conversion.xml', + # Starter recipes are NOT in 'data' on purpose. They get + # loaded once via post_init_hook → _seed_starter_recipes_once + # so user edits / deletions survive every -u upgrade. Putting + # them back here would re-create deleted nodes on every + # module upgrade (the noupdate="1" flag only blocks UPDATE, + # not CREATE-when-missing — Odoo treats a missing ir.model.data + # record as "needs creating"). + # 'data/fp_recipe_enp_alum_basic.xml', + # 'data/fp_recipe_enp_steel_basic.xml', + # 'data/fp_recipe_enp_sp.xml', + # 'data/fp_recipe_general_processing.xml', + # 'data/fp_recipe_anodize.xml', + # 'data/fp_recipe_chem_conversion.xml', 'data/fp_step_template_data.xml', ], 'post_init_hook': 'post_init_hook', diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py index 8fcca193..8819bfb4 100644 --- a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py +++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py @@ -9,7 +9,7 @@ enforced by the underlying ACL on fp.step.template + process.node: operators get read; supervisors+ get write. """ -from odoo import http +from odoo import _, http from odoo.http import request @@ -667,11 +667,137 @@ class SimpleRecipeController(http.Controller): @http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user') def step_reorder(self, node_ids): + """Renumber sequence within each parent group. + + Naive version (pre-19.0.20.5.0): renumber the entire flat list + 1..N regardless of parent. Broke when the flat list mixed + operations and substeps — siblings got out-of-order numbers + because the list interleaved them. + + New version: group node ids by their parent_id, then renumber + within each parent. Substeps stay sequenced under their + operation; operations stay sequenced under the recipe / sub- + process. Drop-across-parent shows up as a same-position no-op + — the UI's Promote/Demote buttons are the way to change + parents. + """ Node = request.env['fusion.plating.process.node'] - for i, nid in enumerate(node_ids, start=1): - Node.browse(nid).write({'sequence': i * 10}) + nodes = Node.browse([int(n) for n in node_ids]) + # Group by parent_id (preserve client-provided order within each). + from collections import OrderedDict + by_parent = OrderedDict() + for n in nodes: + by_parent.setdefault(n.parent_id.id, []).append(n) + for parent_id, siblings in by_parent.items(): + for i, n in enumerate(siblings, start=1): + target = i * 10 + if n.sequence != target: + n.sequence = target return {'ok': True} + @http.route('/fp/simple_recipe/step/promote', type='jsonrpc', auth='user') + def step_promote(self, node_id): + """Promote a substep (`step` node) to an operation under the + recipe root. + + Use case: author added a sub-step under an operation in the + Tree Editor, but actually wants it as a standalone operation + that the operator clocks separately. This call: + 1. Flips node_type 'step' → 'operation' + 2. Re-parents to the recipe root (or sub-process root if + the parent operation lives inside a sub_process) + 3. Places the new operation immediately after its old + parent (so it shows up in a sensible position in the + editor list) + """ + Node = request.env['fusion.plating.process.node'] + node = Node.browse(int(node_id)) + if not node.exists(): + return {'ok': False, 'error': 'not_found'} + node.check_access('write') + if node.node_type != 'step': + return {'ok': False, 'error': 'not_a_substep', + 'message': 'Only substeps can be promoted.'} + parent_op = node.parent_id + if not parent_op or parent_op.node_type != 'operation': + return {'ok': False, 'error': 'no_parent_op', + 'message': 'Substep has no operation parent to promote out of.'} + new_parent = parent_op.parent_id + if not new_parent or new_parent.node_type not in ('recipe', 'sub_process'): + return {'ok': False, 'error': 'no_grandparent', + 'message': 'Cannot find a recipe / sub-process to promote into.'} + # Place the new operation right after parent_op. + new_seq = parent_op.sequence + 1 + # Bump later siblings to make room (so we don't collide). + for sibling in new_parent.child_ids.filtered( + lambda s: s.sequence > parent_op.sequence and s.id != node.id + ): + sibling.sequence = sibling.sequence + 10 + node.write({ + 'node_type': 'operation', + 'parent_id': new_parent.id, + 'sequence': new_seq, + }) + return {'ok': True, 'new_parent_id': new_parent.id, + 'new_sequence': new_seq} + + @http.route('/fp/simple_recipe/step/demote', type='jsonrpc', auth='user') + def step_demote(self, node_id, target_op_id=False): + """Demote an operation to a substep under another operation. + + If ``target_op_id`` is provided, the node becomes a substep of + that operation. Otherwise it falls under the operation + immediately preceding it in the editor list (most common case + — author drops a header into the preceding section). + """ + Node = request.env['fusion.plating.process.node'] + node = Node.browse(int(node_id)) + if not node.exists(): + return {'ok': False, 'error': 'not_found'} + node.check_access('write') + if node.node_type != 'operation': + return {'ok': False, 'error': 'not_an_operation', + 'message': 'Only operations can be demoted to substeps.'} + # Substeps of operations don't recurse further — bail if this + # operation has its own step children (would lose them on demote). + if node.child_ids: + return {'ok': False, 'error': 'has_children', + 'message': ( + 'Operation "%s" has %d child step(s). Remove ' + 'or promote them first before demoting this ' + 'operation.' + ) % (node.name, len(node.child_ids))} + # Resolve target operation. + if target_op_id: + target = Node.browse(int(target_op_id)) + if not target.exists() or target.node_type != 'operation': + return {'ok': False, 'error': 'invalid_target', + 'message': 'Target must be an operation.'} + else: + # Find the preceding operation in the same parent. + parent = node.parent_id + if not parent: + return {'ok': False, 'error': 'no_parent'} + siblings = parent.child_ids.sorted('sequence') + before = [s for s in siblings if s.sequence < node.sequence + and s.node_type == 'operation'] + if not before: + return {'ok': False, 'error': 'no_preceding_op', + 'message': ( + 'There is no preceding operation to demote ' + 'into. Add one above this step first, or ' + 'pick an operation manually.' + )} + target = before[-1] + # Place the substep at the end of the target operation's children. + last_seq = max(target.child_ids.mapped('sequence') or [0]) + node.write({ + 'node_type': 'step', + 'parent_id': target.id, + 'sequence': last_seq + 10, + }) + return {'ok': True, 'new_parent_id': target.id} + # -------------------------------------------------------------- template @http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user') def template_list(self): diff --git a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js index 1e290f66..f8fd9b40 100644 --- a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js +++ b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js @@ -152,6 +152,61 @@ export class FpSimpleRecipeEditor extends Component { await this.loadAll(); } + // ---- Promote / demote ------------------------------------------------- + // + // Substep → operation: turn a child step into a top-level operation + // under the recipe root (or sub-process root if applicable). + // Operation → substep: tuck a top-level operation under the + // preceding operation as one of its substeps. Handy when the author + // realises a "header" should actually live as part of another + // operation's workflow. + + async onPromoteStep(stepId) { + const proceed = await this._confirm( + _t( + "Promote this substep to a top-level operation? It will be " + + "moved out of its parent operation and placed directly under " + + "the recipe." + ) + ); + if (!proceed) return; + const res = await rpc("/fp/simple_recipe/step/promote", { + node_id: stepId, + }); + if (!res.ok) { + this.notification.add( + res.message || _t("Could not promote step."), + { type: "warning" } + ); + return; + } + await this.loadAll(); + this.notification.add(_t("Step promoted to operation."), { type: "success" }); + } + + async onDemoteStep(stepId) { + const proceed = await this._confirm( + _t( + "Demote this operation to a substep under the previous " + + "operation? It will be tucked underneath the operation " + + "immediately above it in the list." + ) + ); + if (!proceed) return; + const res = await rpc("/fp/simple_recipe/step/demote", { + node_id: stepId, + }); + if (!res.ok) { + this.notification.add( + res.message || _t("Could not demote step."), + { type: "warning" } + ); + return; + } + await this.loadAll(); + this.notification.add(_t("Operation demoted to substep."), { type: "success" }); + } + async onAddInlineStep() { await rpc("/fp/simple_recipe/step/insert", { recipe_id: this._recipeId, diff --git a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss index bf30de56..a2703a8d 100644 --- a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss +++ b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss @@ -255,6 +255,21 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex}); cursor: default; } } + .o_fp_step_promote, + .o_fp_step_demote { + background: none; + border: none; + color: $fp-se-muted; + padding: .2rem .4rem; + cursor: pointer; + font-size: .85rem; + border-radius: 4px; + transition: background .12s ease, color .12s ease; + &:hover { + background: $fp-se-page; + color: $fp-se-accent; + } + } .o_fp_step_edit, .o_fp_step_remove { background: none; diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml index 9451a9cb..7657730c 100644 --- a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml +++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml @@ -69,11 +69,11 @@
- + . @@ -107,6 +107,18 @@ + +