Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_process_node.py
gsinghpal 274236d34c fix(plating): rename opt_in_out labels — 'Required' replaces 'Disabled'
User feedback: "Disabled" was confusing; it looked like the step
was turned off entirely when in fact the semantic meant "every job
runs this, cannot be removed". Aligning labels with Steelhead's
own terminology and the mental model the shop floor already uses:

  Python Selection values (unchanged: disabled / opt_out / opt_in)
    disabled  →  "Required"
    opt_out   →  "Opt-Out (included by default, can be removed per job)"
    opt_in    →  "Opt-In (excluded by default, can be added per job)"

  Tree-editor side panel inline labels match, plus a short helper
  line under the dropdown:
    "Required — every job runs this step.
     Opt-Out — ships included, estimator can remove per job.
     Opt-In — ships excluded, estimator can add per job."

Field string also flipped from "Opt In/Out" to "Step Usage" — the
new header reads closer to what the field actually controls.

Column order also flipped so the Opt-Out option appears above
Opt-In — matches the frequency in real recipes (most optional
steps are included by default and sometimes skipped, not the other
way around).

fusion_plating → 19.0.7.4.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:24:57 -04:00

522 lines
18 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
from .fp_tz import fp_isoformat_utc
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', 'Required'),
('opt_out', 'Opt-Out (included by default, can be removed per job)'),
('opt_in', 'Opt-In (excluded by default, can be added per job)'),
],
string='Step Usage',
default='disabled',
help='Controls whether this step can be skipped or added on a '
'per-job basis:\n'
' * Required — every job runs this step. Cannot be removed.\n'
' * Opt-Out — included by default; an estimator can remove '
'it per job when the customer doesn\'t need it.\n'
' * Opt-In — excluded by default; an estimator can add it '
'per job when the customer specifically asks for it.',
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,
)
# ---- Part ownership & provenance (Sub 3) --------------------------------
# Sub 3 fields (part_catalog_id, cloned_from_id, treatment_uom) are
# declared as an inherit in fusion_plating_configurator — they need
# to reference fp.part.catalog, which lives in configurator (a child
# module). Adding them here would create a circular dependency.
# See fusion_plating_configurator/models/fp_process_node_inherit.py.
# ---- Recipe-only fields (apply when node_type='recipe') -----------------
# These migrate Steelhead's recipe-level metadata: lead time, the
# product/service tied to this recipe, the contract review approver
# roster, and the pricing builders to apply when this recipe is on
# a quote. They're loose-coupled to keep non-recipe nodes clean.
default_lead_time = fields.Float(
string='Default Lead Time (days)',
digits=(8, 2),
help='When an MO is created using this recipe, '
'date_planned_finished is set to NOW + lead_time.',
tracking=True,
)
product_id = fields.Many2one(
'product.product',
string='Service / Product',
ondelete='set null',
help='The plating service product this recipe sells. When the '
'product appears on a sale order, the resulting MO can '
'auto-pick this recipe.',
tracking=True,
)
contract_review_user_ids = fields.Many2many(
'res.users',
relation='fp_process_node_contract_review_user_rel',
column1='node_id',
column2='user_id',
string='Contract Review Approvers',
help='Users authorised to sign off the Contract Review work order '
'on jobs running this recipe. Anyone outside this list will '
'be blocked from finishing the WO.',
)
# NB. `pricing_rule_ids` lives in fusion_plating_configurator
# (added there so this core module doesn't depend on the configurator).
# ---- 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.'))
# ---- Version auto-bump ---------------------------------------------------
# Any meaningful edit / add / delete inside a recipe bumps the recipe
# root's `version` field by one. Lets shop managers see at a glance
# how stable a recipe is and (later) lets a job pin to a specific
# recipe revision so already-running MOs don't see mid-flight changes.
# Fields that don't represent a "meaningful" change — adjusting these
# alone does not bump the version. `version` itself is in the list to
# avoid an infinite write loop.
_FP_NON_VERSIONED_FIELDS = {
'version', 'write_date', 'write_uid',
'create_date', 'create_uid',
'parent_path', 'display_name', 'recipe_root_id', 'depth',
}
def _fp_bump_recipe_versions(self):
"""Increment `version` by 1 on the distinct recipe roots covering
the current recordset."""
roots = self.mapped('recipe_root_id')
# _compute_recipe_root_id falls back to self for nodes whose
# parent_path isn't yet stored — pick those up too.
for rec in self:
if not rec.recipe_root_id and rec.node_type == 'recipe':
roots |= rec
if not roots:
return
# Use a direct SQL update so we (a) skip our own write override
# and (b) avoid touching write_date / write_uid on the root,
# which would itself be a no-op-but-noisy chatter event.
self.env.cr.execute(
'UPDATE fusion_plating_process_node '
'SET version = COALESCE(version, 0) + 1 '
'WHERE id IN %s',
(tuple(roots.ids),),
)
roots.invalidate_recordset(['version'])
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
# Skip non-recipe roots — only count when the new node lives
# inside an existing recipe.
descendants = records.filtered(lambda r: r.node_type != 'recipe')
if descendants:
descendants._fp_bump_recipe_versions()
return records
def write(self, vals):
meaningful = bool(set(vals.keys()) - self._FP_NON_VERSIONED_FIELDS)
res = super().write(vals)
if meaningful and self:
self._fp_bump_recipe_versions()
return res
def unlink(self):
# Snapshot the affected recipe roots BEFORE delete, otherwise
# recipe_root_id becomes unreachable on the deleted records.
roots = self.mapped('recipe_root_id')
descendants = self.filtered(lambda r: r.node_type != 'recipe')
# Delete first so we don't bump the version of a recipe that's
# being removed entirely.
res = super().unlink()
survivors = roots.exists()
if descendants and survivors:
survivors._fp_bump_recipe_versions()
return res
# ---- 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),
# ISO with explicit UTC marker so JS new Date() parses it
# correctly and re-localises to the browser's timezone.
'create_date': fp_isoformat_utc(self.create_date),
'create_uid_name': self.create_uid.name if self.create_uid else '',
'write_date': fp_isoformat_utc(self.write_date),
'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).',
)