Files
Odoo-Modules/fusion-plating/fusion_plating/models/fp_process_node.py
gsinghpal e4b41828a3 fusion_plating: add opt_in_out field + time tracking display (v19.0.2.0.4)
New opt_in_out selection field (disabled/opt-in/opt-out) matching
Steelhead's Configure OPT IN/OUT feature. Shown in both the form
view and the tree editor side panel.

Time tracking: form view now shows Created, Created By, Last Updated,
Updated By fields. Tree editor side panel shows relative timestamps
down to the second (e.g. "46w 3d 4h 17m 21s ago by Brett Kinzett").

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

377 lines
12 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.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.',
)
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).',
)