feat(configurator): Sub 3 Phase B — part-scoped Process Composer client action + part form Compose button

- 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
This commit is contained in:
gsinghpal
2026-04-22 09:02:03 -04:00
parent 7d5c826f3e
commit 3de37ea735
7 changed files with 541 additions and 1 deletions

View File

@@ -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);