Files
Odoo-Modules/fusion-plating/fusion_plating/models/fp_process_node.py
gsinghpal 330112f29e fusion_plating: change icon from Char to Selection dropdown (v19.0.2.0.5)
Icon field is now a selection with 24 curated plating icons. Users pick
from a dropdown with descriptive labels (e.g. "Fire / Bake", "Diamond /
Plating") instead of typing FA class codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:10:21 -04:00

402 lines
13 KiB
Python

# -*- 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,
)
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.Selection(
[
('fa-flask', 'Flask / Chemistry'),
('fa-industry', 'Industry / Line'),
('fa-sitemap', 'Sitemap / Process'),
('fa-wrench', 'Wrench / Operation'),
('fa-cog', 'Gear / General'),
('fa-cogs', 'Gears / System'),
('fa-paint-brush', 'Paint / Masking'),
('fa-eraser', 'Eraser / De-Masking'),
('fa-th', 'Grid / Racking'),
('fa-fire', 'Fire / Bake'),
('fa-bolt', 'Bolt / Electric'),
('fa-diamond', 'Diamond / Plating'),
('fa-tint', 'Tint / Rinse'),
('fa-shower', 'Shower / Clean'),
('fa-bullseye', 'Target / Blast'),
('fa-search', 'Search / Inspect'),
('fa-check-circle', 'Check / Approve'),
('fa-clock-o', 'Clock / Wait'),
('fa-sun-o', 'Sun / Dry'),
('fa-thermometer-half', 'Temp / Heat'),
('fa-eye', 'Eye / Visual'),
('fa-hand-paper-o', 'Hand / Manual'),
('fa-cube', 'Cube / Part'),
('fa-shield', 'Shield / Protect'),
],
string='Icon',
default='fa-cog',
)
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.',
)
opt_in_out = fields.Selection(
[
('disabled', 'Disabled'),
('opt_in', 'Opt-In'),
('opt_out', 'Opt-Out'),
],
string='Opt In/Out',
default='disabled',
help='Controls whether this step is optional for a given job.',
tracking=True,
)
# ---- 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),
'opt_in_out': self.opt_in_out or 'disabled',
'input_count': len(self.input_ids),
'create_date': self.create_date.isoformat() if self.create_date else '',
'create_uid_name': self.create_uid.name if self.create_uid else '',
'write_date': self.write_date.isoformat() if self.write_date else '',
'write_uid_name': self.write_uid.name if self.write_uid else '',
'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).',
)