# -*- 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).', )