fusion_plating: add process recipe system with OWL tree editor (v19.0.2.0.0)
New model fusion.plating.process.node with _parent_store hierarchy for defining reusable plating recipes. Node types: recipe, sub_process, operation, step. Includes companion model for operator input definitions. Full OWL tree editor (client action) with: - Hierarchical tree with connector lines and type-coloured borders - Click-to-edit side panel with save - Add/delete child nodes inline - Drag & drop reorder and reparent - Theme-aware SCSS (light + dark mode) - Demo data: Electroless Nickel Plating — Steel Line (30+ nodes) Backend: 7 JSON-RPC endpoints for tree CRUD, reorder, move, duplicate. Security: 3-tier ACL (operator read / supervisor write / manager full). Menu: Process Recipes under Plating > Operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
361
fusion-plating/fusion_plating/models/fp_process_node.py
Normal file
361
fusion-plating/fusion_plating/models/fp_process_node.py
Normal file
@@ -0,0 +1,361 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
"""A node in the process recipe tree.
|
||||
|
||||
Recipes are hierarchical templates that define how to plate a part.
|
||||
They are reusable across production orders and serve as the single
|
||||
source of truth for the shop's plating processes.
|
||||
|
||||
Node types
|
||||
----------
|
||||
* recipe — top-level root (e.g. "Electroless Nickel — Steel Line")
|
||||
* sub_process — a group of operations (e.g. "Steel Line", "Cleaner")
|
||||
* operation — a single production step (e.g. "Acid Dip", "Nickel Strike")
|
||||
* step — a sub-step within an operation (e.g. "Ready for Blast", "Blast")
|
||||
|
||||
Hierarchy uses Odoo's _parent_store for efficient tree queries.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node'
|
||||
_description = 'Fusion Plating — Process Node'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_parent_store = True
|
||||
_parent_name = 'parent_id'
|
||||
_order = 'parent_path, sequence, id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# ---- Identity & hierarchy ------------------------------------------------
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
help='Optional short code (e.g. EN_STEEL).',
|
||||
tracking=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
[
|
||||
('recipe', 'Recipe'),
|
||||
('sub_process', 'Sub-Process'),
|
||||
('operation', 'Operation'),
|
||||
('step', 'Step'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
default='operation',
|
||||
tracking=True,
|
||||
)
|
||||
parent_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Parent',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
parent_path = fields.Char(
|
||||
index=True,
|
||||
unaccent=False,
|
||||
)
|
||||
child_ids = fields.One2many(
|
||||
'fusion.plating.process.node',
|
||||
'parent_id',
|
||||
string='Child Steps',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
depth = fields.Integer(
|
||||
string='Depth',
|
||||
compute='_compute_depth',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Process references --------------------------------------------------
|
||||
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type',
|
||||
string='Process Type',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Centre',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Content & metadata --------------------------------------------------
|
||||
|
||||
description = fields.Html(
|
||||
string='Description',
|
||||
help='Rich text instructions for this step.',
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Internal Notes',
|
||||
help='Internal notes (not shown to customers).',
|
||||
)
|
||||
icon = fields.Char(
|
||||
string='Icon',
|
||||
default='fa-cog',
|
||||
help='Font Awesome icon class.',
|
||||
)
|
||||
color = fields.Integer(
|
||||
string='Colour',
|
||||
default=0,
|
||||
)
|
||||
|
||||
# ---- Timing --------------------------------------------------------------
|
||||
|
||||
estimated_duration = fields.Float(
|
||||
string='Estimated Duration (min)',
|
||||
help='Expected time in minutes.',
|
||||
)
|
||||
|
||||
# ---- Behaviour flags -----------------------------------------------------
|
||||
|
||||
auto_complete = fields.Boolean(
|
||||
string='Auto-Complete',
|
||||
default=False,
|
||||
help='Automatically marks done when all children complete.',
|
||||
)
|
||||
customer_visible = fields.Boolean(
|
||||
string='Customer Visible',
|
||||
default=True,
|
||||
help='Whether to show this step name to customers.',
|
||||
)
|
||||
is_manual = fields.Boolean(
|
||||
string='Manual Operation',
|
||||
default=True,
|
||||
help='Unchecked = automated (e.g. timed immersion).',
|
||||
)
|
||||
requires_signoff = fields.Boolean(
|
||||
string='Requires Sign-Off',
|
||||
default=False,
|
||||
help='Quality hold point — requires operator sign-off.',
|
||||
)
|
||||
|
||||
# ---- Lifecycle -----------------------------------------------------------
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
version = fields.Integer(
|
||||
string='Version',
|
||||
default=1,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Computed fields -----------------------------------------------------
|
||||
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
recursive=True,
|
||||
)
|
||||
child_count = fields.Integer(
|
||||
string='Children',
|
||||
compute='_compute_child_count',
|
||||
)
|
||||
recipe_root_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe Root',
|
||||
compute='_compute_recipe_root_id',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Operator inputs (one2many) ------------------------------------------
|
||||
|
||||
input_ids = fields.One2many(
|
||||
'fusion.plating.process.node.input',
|
||||
'node_id',
|
||||
string='Operator Inputs',
|
||||
)
|
||||
|
||||
# ---- SQL constraints -----------------------------------------------------
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_process_node_code_uniq',
|
||||
'unique(code)',
|
||||
'Recipe node code must be unique.'),
|
||||
]
|
||||
|
||||
# ---- Computes ------------------------------------------------------------
|
||||
|
||||
@api.depends('name', 'code', 'parent_id.display_name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
if rec.parent_id and rec.node_type != 'recipe':
|
||||
rec.display_name = f'{rec.parent_id.display_name} / {rec.name}'
|
||||
else:
|
||||
rec.display_name = rec.name or ''
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_depth(self):
|
||||
for rec in self:
|
||||
rec.depth = (rec.parent_path or '').count('/') - 1
|
||||
|
||||
@api.depends('child_ids')
|
||||
def _compute_child_count(self):
|
||||
for rec in self:
|
||||
rec.child_count = len(rec.child_ids)
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_recipe_root_id(self):
|
||||
for rec in self:
|
||||
if rec.parent_path:
|
||||
root_id = int(rec.parent_path.split('/')[0])
|
||||
rec.recipe_root_id = root_id
|
||||
else:
|
||||
rec.recipe_root_id = rec.id
|
||||
|
||||
# ---- Constraints ---------------------------------------------------------
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _check_recursion_constraint(self):
|
||||
if not self._check_recursion():
|
||||
raise ValidationError(
|
||||
_('A process node cannot be its own ancestor.'))
|
||||
|
||||
# ---- Tree data for OWL component -----------------------------------------
|
||||
|
||||
def get_tree_data(self):
|
||||
"""Return full nested dict for the OWL recipe tree editor.
|
||||
|
||||
Called via the controller. Returns the tree rooted at `self`,
|
||||
recursively including all descendants.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self._node_to_dict()
|
||||
|
||||
def _node_to_dict(self, max_depth=10):
|
||||
"""Recursively convert this node + children to a dict."""
|
||||
if max_depth <= 0:
|
||||
return None
|
||||
children = []
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child_dict = child._node_to_dict(max_depth=max_depth - 1)
|
||||
if child_dict:
|
||||
children.append(child_dict)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name or '',
|
||||
'code': self.code or '',
|
||||
'node_type': self.node_type,
|
||||
'sequence': self.sequence,
|
||||
'depth': self.depth,
|
||||
'icon': self.icon or 'fa-cog',
|
||||
'color': self.color,
|
||||
'process_type': self.process_type_id.name if self.process_type_id else '',
|
||||
'process_type_id': self.process_type_id.id if self.process_type_id else False,
|
||||
'work_center': self.work_center_id.name if self.work_center_id else '',
|
||||
'work_center_id': self.work_center_id.id if self.work_center_id else False,
|
||||
'description': self.description or '',
|
||||
'notes': self.notes or '',
|
||||
'estimated_duration': self.estimated_duration,
|
||||
'auto_complete': self.auto_complete,
|
||||
'customer_visible': self.customer_visible,
|
||||
'is_manual': self.is_manual,
|
||||
'requires_signoff': self.requires_signoff,
|
||||
'version': self.version,
|
||||
'child_count': len(children),
|
||||
'input_count': len(self.input_ids),
|
||||
'children': children,
|
||||
}
|
||||
|
||||
# ---- Actions -------------------------------------------------------------
|
||||
|
||||
def action_open_tree_editor(self):
|
||||
"""Open the OWL recipe tree editor for this recipe."""
|
||||
self.ensure_one()
|
||||
root = self if self.node_type == 'recipe' else self.recipe_root_id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_recipe_tree_editor',
|
||||
'name': f'Recipe — {root.name}',
|
||||
'context': {'recipe_id': root.id},
|
||||
}
|
||||
|
||||
# ---- Copy (deep-duplicate) -----------------------------------------------
|
||||
|
||||
def copy(self, default=None):
|
||||
"""Deep-copy: duplicates the node and all descendants."""
|
||||
default = dict(default or {})
|
||||
if self.node_type == 'recipe':
|
||||
default.setdefault('name', _('%s (Copy)', self.name))
|
||||
default.setdefault('code', f'{self.code}_copy' if self.code else False)
|
||||
new_node = super().copy(default)
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child.copy({'parent_id': new_node.id})
|
||||
return new_node
|
||||
|
||||
|
||||
class FpProcessNodeInput(models.Model):
|
||||
"""An operator input definition attached to a process node.
|
||||
|
||||
These define what the operator needs to record when executing this
|
||||
step — temperature readings, visual inspections, timing, etc.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node.input'
|
||||
_description = 'Fusion Plating — Process Node Input'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='E.g. "Temperature Reading", "Visual Inspection".',
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
input_type = fields.Selection(
|
||||
[
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes / No'),
|
||||
('selection', 'Selection'),
|
||||
('photo', 'Photo'),
|
||||
],
|
||||
string='Input Type',
|
||||
required=True,
|
||||
default='text',
|
||||
)
|
||||
required = fields.Boolean(
|
||||
string='Required',
|
||||
default=False,
|
||||
)
|
||||
hint = fields.Char(
|
||||
string='Hint',
|
||||
help='Placeholder text shown to the operator.',
|
||||
)
|
||||
selection_options = fields.Text(
|
||||
string='Options',
|
||||
help='Comma-separated list of options (for Selection type).',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
uom = fields.Char(
|
||||
string='Unit',
|
||||
help='Unit label (e.g. °C, min, psi).',
|
||||
)
|
||||
Reference in New Issue
Block a user