From 3de37ea735677ffa850f59b8fdf9218fe4159346 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 22 Apr 2026 09:02:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(configurator):=20Sub=203=20Phase=20B=20?= =?UTF-8?q?=E2=80=94=20part-scoped=20Process=20Composer=20client=20action?= =?UTF-8?q?=20+=20part=20form=20Compose=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fp_part_composer_controller with 3 JSON-RPC endpoints: /fp/part/composer/state, /fp/part/composer/templates, /fp/part/composer/load_template (deep-clones a shared template into a part-owned tree inside a cr.savepoint, sets fp.part.catalog.default_process_id atomically) - _clone_subtree copies name/sequence/opt_in_out/treatment_uom plus description/notes/icon/color/timing/behaviour/work_center/process_type and stamps part_catalog_id + cloned_from_id on every node - Add fp_part_process_composer OWL client action (JS + XML + SCSS): picks template from dropdown, clones, hands off to existing fp_recipe_tree_editor via context={recipe_id, part_id} - Add Process tab on part form with readonly default_process_id field and Compose button calling action_open_part_composer - Register new assets in web.assets_backend, bump configurator version to 19.0.11.0.0 --- .../__manifest__.py | 6 +- .../controllers/__init__.py | 1 + .../fp_part_composer_controller.py | 196 ++++++++++++++++++ .../static/src/js/fp_part_process_composer.js | 147 +++++++++++++ .../src/scss/fp_part_process_composer.scss | 84 ++++++++ .../src/xml/fp_part_process_composer.xml | 90 ++++++++ .../views/fp_part_catalog_views.xml | 18 ++ 7 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 fusion_plating/fusion_plating_configurator/controllers/fp_part_composer_controller.py create mode 100644 fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js create mode 100644 fusion_plating/fusion_plating_configurator/static/src/scss/fp_part_process_composer.scss create mode 100644 fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index 1ef99199..e3dba900 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Configurator', - 'version': '19.0.10.0.0', + 'version': '19.0.11.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'description': """ @@ -64,6 +64,10 @@ Provides: 'fusion_plating_configurator/static/src/js/fp_drawing_preview.js', 'fusion_plating_configurator/static/src/xml/fp_pdf_inline_preview.xml', 'fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js', + # Sub 3 — part-scoped Process Composer + 'fusion_plating_configurator/static/src/scss/fp_part_process_composer.scss', + 'fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml', + 'fusion_plating_configurator/static/src/js/fp_part_process_composer.js', ], }, 'installable': True, diff --git a/fusion_plating/fusion_plating_configurator/controllers/__init__.py b/fusion_plating/fusion_plating_configurator/controllers/__init__.py index 034d6501..8454edb7 100644 --- a/fusion_plating/fusion_plating_configurator/controllers/__init__.py +++ b/fusion_plating/fusion_plating_configurator/controllers/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import configurator_controller +from . import fp_part_composer_controller diff --git a/fusion_plating/fusion_plating_configurator/controllers/fp_part_composer_controller.py b/fusion_plating/fusion_plating_configurator/controllers/fp_part_composer_controller.py new file mode 100644 index 00000000..56ddff88 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/controllers/fp_part_composer_controller.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +# +# Sub 3 — part-scoped Process Composer RPC. +# +# Endpoints: +# POST /fp/part/composer/state — part info + current tree status +# POST /fp/part/composer/templates — list shared-template recipes +# POST /fp/part/composer/load_template — clone a shared template into a part + +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +# Node fields we defensively copy from source → cloned node. +# Only include fields that definitely exist on fusion.plating.process.node. +# Verified against fusion_plating/models/fp_process_node.py. +_CLONABLE_FIELDS = ( + 'description', + 'notes', + 'icon', + 'color', + 'estimated_duration', + 'auto_complete', + 'customer_visible', + 'is_manual', + 'requires_signoff', + 'active', + 'process_type_id', + 'work_center_id', +) + + +def _clone_subtree(env, source, part, parent): + """Recursively clone a process node subtree for a specific part. + + Creates a new node mirroring ``source`` with part ownership set, + links it to ``cloned_from_id``, then recurses into child nodes. + + :param env: active Odoo environment. + :param source: source fusion.plating.process.node (shared template or otherwise). + :param part: fp.part.catalog record receiving the clone. + :param parent: parent fusion.plating.process.node for the new node, or False for root. + :return: newly created root node (recordset). + """ + Node = env['fusion.plating.process.node'] + + vals = { + 'name': source.name, + 'code': False, # codes must be globally unique; don't carry over + 'node_type': source.node_type, + 'sequence': source.sequence, + 'opt_in_out': source.opt_in_out, + 'treatment_uom': source.treatment_uom, + 'part_catalog_id': part.id, + 'cloned_from_id': source.id, + 'parent_id': parent.id if parent else False, + } + + # Copy additional fields defensively — skip anything missing on the + # model (future-safe for field removals). + for fname in _CLONABLE_FIELDS: + if fname in source._fields: + try: + value = source[fname] + # Many2one → extract id; everything else passes through. + if source._fields[fname].type == 'many2one': + vals[fname] = value.id if value else False + else: + vals[fname] = value + except Exception: + # Field exists but read failed — ignore and move on. + pass + + new_node = Node.create(vals) + + # Recurse into children in deterministic sequence order. + for child in source.child_ids.sorted('sequence'): + _clone_subtree(env, child, part, new_node) + + return new_node + + +class FpPartComposerController(http.Controller): + """JSON-RPC endpoints for the part-scoped Process Composer.""" + + # ------------------------------------------------------------------ + # Read — current part + tree status + # ------------------------------------------------------------------ + @http.route('/fp/part/composer/state', type='jsonrpc', auth='user') + def state(self, part_id): + """Return part info plus the current default_process_id tree (or None).""" + part = request.env['fp.part.catalog'].browse(int(part_id)).exists() + if not part: + return {'ok': False, 'error': 'Part not found'} + root = part.default_process_id + return { + 'ok': True, + 'part': { + 'id': part.id, + 'part_number': part.part_number or '', + 'revision': part.revision or '', + 'name': part.name or '', + 'display': part.display_name or '', + 'customer': part.partner_id.display_name or '', + }, + 'has_tree': bool(root), + 'root_id': root.id if root else False, + } + + # ------------------------------------------------------------------ + # List — shared-template recipes + # ------------------------------------------------------------------ + @http.route('/fp/part/composer/templates', type='jsonrpc', auth='user') + def templates(self): + """Return shared-template recipes (part_catalog_id IS NULL, node_type='recipe').""" + Node = request.env['fusion.plating.process.node'] + templates = Node.search([ + ('part_catalog_id', '=', False), + ('node_type', '=', 'recipe'), + ('active', '=', True), + ], order='name asc') + return { + 'ok': True, + 'templates': [ + {'id': t.id, 'name': t.name or '', 'code': t.code or ''} + for t in templates + ], + } + + # ------------------------------------------------------------------ + # Write — clone a template into the part + # ------------------------------------------------------------------ + @http.route('/fp/part/composer/load_template', type='jsonrpc', auth='user') + def load_template(self, part_id, template_id): + """Clone a shared template into a part-scoped tree. + + Deletes any existing part-owned tree for this part first, then + deep-clones the template subtree with part ownership set. Finally + pins ``part.default_process_id`` to the new root. + + The whole operation runs inside a savepoint — if anything fails + partway through, the part is left in its previous state. + """ + part = request.env['fp.part.catalog'].browse(int(part_id)).exists() + tpl = request.env['fusion.plating.process.node'].browse(int(template_id)).exists() + if not part: + return {'ok': False, 'error': 'Part not found'} + if not tpl: + return {'ok': False, 'error': 'Template not found'} + if tpl.part_catalog_id: + return {'ok': False, 'error': 'Invalid template (must be a shared-template recipe)'} + if tpl.node_type != 'recipe': + return {'ok': False, 'error': 'Template must be a recipe-type node'} + + try: + with request.env.cr.savepoint(): + # 1. Delete any prior part-owned tree for this part. + # parent_id has ondelete='cascade', so deleting root(s) + # wipes their descendants. Use search so we don't assume + # only default_process_id's tree exists. + prior = request.env['fusion.plating.process.node'].search([ + ('part_catalog_id', '=', part.id), + ]) + if prior: + prior.unlink() + + # 2. Deep-clone the template subtree with part ownership. + new_root = _clone_subtree(request.env, tpl, part, parent=False) + + # 3. Pin part.default_process_id to the new root. + part.default_process_id = new_root.id + + node_count = request.env['fusion.plating.process.node'].search_count([ + ('part_catalog_id', '=', part.id), + ]) + + _logger.info( + 'Part Composer: cloned template %s (%s) → part %s (%s), %s nodes, by uid %s', + tpl.id, tpl.name, part.id, part.display_name, + node_count, request.env.uid, + ) + return { + 'ok': True, + 'root_id': new_root.id, + 'node_count': node_count, + } + except Exception as exc: + _logger.exception('Part Composer load_template failed') + return {'ok': False, 'error': str(exc)} diff --git a/fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js b/fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js new file mode 100644 index 00000000..832d3ee8 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/static/src/js/fp_part_process_composer.js @@ -0,0 +1,147 @@ +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — Part-Scoped Process Composer (OWL client action) +// Copyright 2026 Nexa Systems Inc. +// License OPL-1 (Odoo Proprietary License v1.0) +// +// Thin wrapper around the existing recipe tree editor. Gives a part +// its own composed process tree by cloning a shared template, then +// hands off to the fp_recipe_tree_editor action for edits. +// +// Odoo 19 conventions: +// * Backend OWL: static template + static props = ["*"] +// * RPC: standalone rpc() from @web/core/network/rpc +// * Registered under registry.category("actions") → "fp_part_process_composer" +// ============================================================================= + +import { Component, useState, onMounted } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; + +export class FpPartProcessComposer extends Component { + static template = "fusion_plating_configurator.FpPartProcessComposer"; + static props = ["*"]; + + setup() { + this.action = useService("action"); + this.notification = useService("notification"); + + // Pull part_id out of the client action's params (set by + // fp.part.catalog.action_open_part_composer on the server). + const params = (this.props.action && this.props.action.params) || {}; + this.partId = params.part_id || null; + + this.state = useState({ + loading: true, + error: null, + part: null, + hasTree: false, + rootId: null, + templates: [], + selectedTemplateId: null, + loadingTemplate: false, + }); + + onMounted(() => this.refresh()); + } + + // ---- Data loading ------------------------------------------------------- + + async refresh() { + if (!this.partId) { + this.state.error = "No part specified."; + this.state.loading = false; + return; + } + this.state.loading = true; + this.state.error = null; + try { + const [stateRes, tplRes] = await Promise.all([ + rpc("/fp/part/composer/state", { part_id: this.partId }), + rpc("/fp/part/composer/templates", {}), + ]); + if (!stateRes.ok) throw new Error(stateRes.error || "Failed to load part state."); + if (!tplRes.ok) throw new Error(tplRes.error || "Failed to load templates."); + + this.state.part = stateRes.part; + this.state.hasTree = stateRes.has_tree; + this.state.rootId = stateRes.root_id || null; + this.state.templates = tplRes.templates || []; + + // Default the dropdown selection to the first template so the + // user can click Load immediately. + if (this.state.templates.length > 0 && !this.state.selectedTemplateId) { + this.state.selectedTemplateId = this.state.templates[0].id; + } + } catch (err) { + this.state.error = err.message || String(err); + } finally { + this.state.loading = false; + } + } + + // ---- Handlers ----------------------------------------------------------- + + onSelectTemplate(ev) { + this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null; + } + + async onLoadTemplate() { + if (!this.state.selectedTemplateId) return; + const confirmReplace = this.state.hasTree + ? window.confirm("This will replace the current process tree for this part. Continue?") + : true; + if (!confirmReplace) return; + + this.state.loadingTemplate = true; + try { + const res = await rpc("/fp/part/composer/load_template", { + part_id: this.partId, + template_id: this.state.selectedTemplateId, + }); + if (!res.ok) throw new Error(res.error || "Load failed."); + this.notification.add( + `Template loaded — ${res.node_count} nodes cloned into this part's tree.`, + { type: "success" } + ); + await this.refresh(); + // Hand off directly to the tree editor so the user can + // immediately start customising. + this.openRecipeEditor(res.root_id); + } catch (err) { + this.notification.add( + `Load failed: ${err.message || err}`, + { type: "danger" } + ); + } finally { + this.state.loadingTemplate = false; + } + } + + openRecipeEditor(rootId) { + const id = rootId || this.state.rootId; + if (!id) return; + // The existing fp_recipe_tree_editor reads recipe_id from + // this.props.action?.context — pass it via `context`, not `params`. + this.action.doAction({ + type: "ir.actions.client", + tag: "fp_recipe_tree_editor", + name: `Process Composer — ${(this.state.part && this.state.part.display) || ""}`, + context: { recipe_id: id, part_id: this.partId }, + target: "current", + }); + } + + backToPart() { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "fp.part.catalog", + res_id: this.partId, + views: [[false, "form"]], + target: "current", + }); + } +} + +registry.category("actions").add("fp_part_process_composer", FpPartProcessComposer); diff --git a/fusion_plating/fusion_plating_configurator/static/src/scss/fp_part_process_composer.scss b/fusion_plating/fusion_plating_configurator/static/src/scss/fp_part_process_composer.scss new file mode 100644 index 00000000..a2f9d550 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/static/src/scss/fp_part_process_composer.scss @@ -0,0 +1,84 @@ +// Copyright 2026 Nexa Systems Inc. +// License OPL-1 (Odoo Proprietary License v1.0) +// Part of the Fusion Plating product family. +// +// Sub 3 — Process Composer styles. + +.o_fp_part_composer { + padding: 16px; + max-width: 900px; + margin: 0 auto; + + &_state { + padding: 32px; + text-align: center; + color: var(--bs-secondary-color, #666); + + .fa { + margin-right: 8px; + } + } + + &_error { + color: var(--bs-danger, #c00); + } + + &_header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--bs-border-color, #e0e0e0); + } + + &_title { + h2 { + margin: 0; + font-size: 20px; + } + } + + &_loader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; + padding: 16px; + background: var(--bs-tertiary-bg, #f5f5f5); + border-radius: 8px; + + label { + margin: 0; + font-weight: 600; + } + + select { + flex: 1; + max-width: 400px; + } + } + + &_tree { + min-height: 300px; + padding: 24px; + background: var(--bs-body-bg, #ffffff); + border-radius: 8px; + border: 1px solid var(--bs-border-color, #d8dadd); + } + + &_hint, + &_empty { + text-align: center; + padding: 48px 16px; + + .fa { + color: var(--bs-secondary-color, #999); + margin-bottom: 16px; + } + + p { + color: var(--bs-secondary-color, #666); + } + } +} diff --git a/fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml b/fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml new file mode 100644 index 00000000..ecedf29d --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml @@ -0,0 +1,90 @@ + + + + + +
+ +
+ + Loading… +
+
+ +
+ + +
+
+ +
+ +
+

Process Composer —

+ + Customer: + +
+
+ +
+ + + +
+ +
+ +
+

This part has a composed process tree. Click below to open the + full tree editor where you can add, remove, reorder, and configure + the process nodes.

+ +
+
+ +
+ +

No process composed yet.

+

+ Pick a template above and click Load to get started. +

+
+
+
+
+
+
+ +
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml index 85b182ed..e1b74200 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml @@ -144,6 +144,24 @@ + + + + +
+
+

+ The Compose button opens the Process Composer where you can + load a shared template and customise it for this part. When a job runs for + this part, work orders are generated from the composed tree. +

+