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:
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import configurator_controller
|
||||
from . import fp_part_composer_controller
|
||||
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user