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>
402 lines
13 KiB
Python
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).',
|
|
)
|