# -*- 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 from ._fp_uom_selection import FP_UOM_SELECTION 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.', ) # Sub 12d — master switch for runtime data collection. When False the # operator wizard skips this step entirely (no input prompts shown). collect_measurements = fields.Boolean( string='Collect Measurements at Runtime', default=True, help='Master switch. When off, the operator wizard skips this step ' 'entirely (no input prompts shown). Use for housekeeping steps ' 'or when no measurement is needed for this recipe.', ) 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-flash', 'Flash / Discharge'), ('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-check-square-o', 'Checklist / QC'), ('fa-clock-o', 'Clock / Wait'), ('fa-pause-circle', 'Pause / Hold'), ('fa-sun-o', 'Sun / Dry'), ('fa-thermometer-half', 'Temp / Heat'), ('fa-cloud', 'Cloud / Atmosphere'), ('fa-eye', 'Eye / Visual'), ('fa-eye-slash', 'Eye-Slash / Hidden'), ('fa-hand-paper-o', 'Hand / Manual'), ('fa-cube', 'Cube / Part'), ('fa-shield', 'Shield / Protect'), ('fa-inbox', 'Inbox / Receiving'), ('fa-archive', 'Archive / Storage'), ('fa-truck', 'Truck / Ship'), ('fa-paper-plane', 'Paper-Plane / Send'), ('fa-link', 'Link / Chain'), ('fa-scissors', 'Scissors / Cut'), ('fa-server', 'Server / Stack'), ('fa-tachometer', 'Tachometer / Gauge'), ('fa-file-text-o', 'Document / Form'), ('fa-plus-circle', 'Plus / Add'), ], string='Icon', default='fa-cog', ) color = fields.Integer( string='Colour', default=0, ) # ---- Reference images / instruction screenshots ------------------------- # Recipe authors attach photos and screenshots here so operators see # them on the shop floor when running the step. Anything from a # process diagram, masking-line photo, or annotated screenshot of the # WI document. Many2many — supports zero, one, or many images. instruction_attachment_ids = fields.Many2many( 'ir.attachment', 'fp_node_instruction_attachment_rel', 'node_id', 'attachment_id', string='Instruction Images', domain=[('mimetype', 'ilike', 'image/')], help='Reference photos and screenshots that operators see at ' 'runtime. Anything visual that helps them execute the step ' 'correctly — fixture orientation, masking pattern, gauge ' 'reading. Supports multiple images per step.', ) instruction_attachment_count = fields.Integer( string='Instruction Image Count', compute='_compute_instruction_attachment_count', ) @api.depends('instruction_attachment_ids') def _compute_instruction_attachment_count(self): for rec in self: rec.instruction_attachment_count = len(rec.instruction_attachment_ids) # ---- 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.', ) requires_predecessor_done = fields.Boolean( string='Requires Predecessor Done (legacy)', default=False, help='LEGACY per-step opt-in for predecessor enforcement. As of ' '19.0.X, recipes default to enforce_sequential=True so every ' 'step naturally waits for its predecessors. This flag still ' 'works on recipes whose enforce_sequential is False — turn ' 'it on to make a single step block in an otherwise free-flow ' 'recipe.', ) # ===== Sub 13 — sequential step enforcement (recipe + per-step) ========== # Replaces the unused per-step requires_predecessor_done as the primary # enforcement vector. Two layers: # 1. enforce_sequential (recipe root) — entire recipe is sequential # by default. Author can disable for free-flow recipes. # 2. parallel_start (operation step) — escape hatch within a # sequential recipe, for steps that legitimately run in parallel # (e.g. paperwork that doesn't need previous step done). enforce_sequential = fields.Boolean( string='Enforce Sequential Order', default=True, help='Only meaningful on the recipe root node. When True (the ' 'default), every operation under this recipe waits for all ' 'earlier-sequence steps to be done/skipped/cancelled before ' 'it can start. Mark a specific step as Parallel Start to ' 'opt it out. Disable on the recipe to fall back to the ' 'legacy per-step Requires Predecessor Done flag.', ) parallel_start = fields.Boolean( string='Parallel Start', default=False, help='Only meaningful on operation nodes inside a recipe with ' 'Enforce Sequential Order = True. When checked, this step ' 'can be started while earlier-sequence steps are still in ' 'progress (e.g. paperwork or QA review that runs alongside ' 'production).', ) 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', copy=True, ) # ===== Sub 12a — Simple Editor + Step Library extensions ================= # All fields are additive; tree editor + runtime are unaffected. Drag-drop # from the library snapshot-copies these into a new node (no live ref). is_template = fields.Boolean( string='Use as Starter Template', help='When True (and node_type=recipe), this recipe appears in the ' 'Simple Editor\'s "Import starter from template" dropdown.', ) source_template_id = fields.Many2one( 'fp.step.template', string='Source Library Template', ondelete='set null', index=True, help='Snapshot trace — set when this node was created by dragging ' 'a library step in. Editing the template later does not change ' 'this node (snapshot semantics).', ) tank_ids = fields.Many2many( 'fusion.plating.tank', 'fp_node_tank_rel', 'node_id', 'tank_id', string='Allowed Stations', help='Stations the operator may pick at runtime.', ) material_callout = fields.Char( string='Material Callout', help='Short string for traveller "Material" column. Defaults to ' 'process type name if blank.', ) time_min_target = fields.Float(string='Time Min') time_max_target = fields.Float(string='Time Max') time_unit = fields.Selection( [('sec', 'Seconds'), ('min', 'Minutes'), ('hr', 'Hours')], string='Time Unit', default='min', ) temp_min_target = fields.Float(string='Temp Min') temp_max_target = fields.Float(string='Temp Max') temp_unit = fields.Selection( [('F', '°F'), ('C', '°C')], string='Temp Unit', default='F', ) voltage_target = fields.Float(string='Voltage Target') viscosity_target = fields.Float(string='Viscosity Target') requires_rack_assignment = fields.Boolean( string='Requires Rack Assignment', help='Sub 12b — triggers Rack Parts sub-dialog at runtime.', ) requires_transition_form = fields.Boolean( string='Requires Transition Form', help='Sub 12b — opens the transition form before Mark Done.', ) # Sub 14b — User-extensible Step Kinds (was Selection of 24). kind_id = fields.Many2one( 'fp.step.kind', string='Step Kind', ondelete='set null', index=True, help='Pick from the catalog or create a new kind.', ) # Back-compat: code-string accessor that all legacy # `node.default_kind == "cleaning"` comparisons keep using. default_kind = fields.Char( related='kind_id.code', store=True, readonly=True, index=True, string='Step Kind Code', ) preferred_editor = fields.Selection( [ ('tree', 'Tree Editor'), ('simple', 'Simple Editor'), ('auto', 'Use Company Default'), ], string='Preferred Editor', default='auto', help='Which editor opens when this recipe is selected from the ' 'menu list. "Auto" follows the company-level default.', ) # ---- 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, # Sub 13 — sequential enforcement 'enforce_sequential': self.enforce_sequential, 'parallel_start': self.parallel_start, 'requires_predecessor_done': self.requires_predecessor_done, # Sub 14 — workflow milestone trigger (Many2one or False) 'triggers_workflow_state_id': ( self.triggers_workflow_state_id.id if 'triggers_workflow_state_id' in self._fields and self.triggers_workflow_state_id else False ), 'triggers_workflow_state_name': ( self.triggers_workflow_state_id.name if 'triggers_workflow_state_id' in self._fields and self.triggers_workflow_state_id else '' ), '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}, } def action_open_simple_editor(self): """Open the OWL Simple Recipe Editor for this recipe (Sub 12a).""" self.ensure_one() root = self if self.node_type == 'recipe' else self.recipe_root_id return { 'type': 'ir.actions.client', 'tag': 'fp_simple_recipe_editor', 'name': f'Recipe — {root.name}', 'context': {'recipe_id': root.id}, } def _resolve_preferred_editor(self): """Returns 'tree' or 'simple' for this recipe. Per-recipe preferred_editor wins. 'auto' falls back to the company-level default. 'tree' is the final fallback. """ self.ensure_one() if self.preferred_editor in ('tree', 'simple'): return self.preferred_editor return self.env.company.x_fc_default_recipe_editor or 'tree' def action_open_recipe_with_preferred_editor(self): """Routes to whichever editor the recipe (or company) prefers. Used by menu actions / context-menu opens — gives the simple-loving foreman a one-click path that respects their preference without forcing a tree-loving engineer to pick between two buttons every time. """ self.ensure_one() if self._resolve_preferred_editor() == 'simple': return self.action_open_simple_editor() return self.action_open_tree_editor() # ---- 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'), # Sub 12a — typed inputs the simple editor + traveller need ('time_hms', 'Time (HH:MM:SS)'), ('time_seconds', 'Time (seconds)'), ('temperature', 'Temperature'), ('thickness', 'Thickness'), ('pass_fail', 'Pass / Fail'), ('date', 'Date / Time'), ('signature', 'Signature'), ('location_picker', 'Location Picker'), ('customer_wo', 'Customer WO #'), ('photo', 'Photo'), ('multi_point_thickness', 'Multi-Point Thickness (avg)'), ('bath_chemistry_panel', 'Bath Chemistry Panel'), ('ph', 'pH'), ], 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.Selection( FP_UOM_SELECTION, string='Unit', help='Unit the operator is recording in (pick from the curated list — ' 'avoids "kg" vs "kgs" vs "kilo" inconsistencies).', ) # ===== Sub 12a — kind + target ranges + compliance tag ================== kind = fields.Selection( [ ('step_input', 'Step Measurement'), ('transition_input', 'Transition Form Field'), ], string='Kind', default='step_input', index=True, help='step_input = recorded during the step. transition_input = ' 'recorded when leaving the step (Sub 12b uses these in the ' 'Move Parts dialog).', ) target_min = fields.Float( string='Target Min', digits=(16, 6), help='Lower bound of the acceptable range, expressed in Target Unit. ' 'Stored to 6 decimal places to support plating thicknesses ' '(e.g. 0.000050 in / 50 micro-inches).', ) target_max = fields.Float( string='Target Max', digits=(16, 6), help='Upper bound of the acceptable range, expressed in Target Unit. ' 'Stored to 6 decimal places.', ) target_unit = fields.Selection( FP_UOM_SELECTION, string='Target Unit', help='Unit Target Min / Target Max are measured in.', ) compliance_tag = fields.Selection( [ ('none', 'None'), ('as9100', 'AS9100'), ('nadcap', 'Nadcap'), ('cgp', 'Controlled Goods'), ('nuclear', 'Nuclear'), ], string='Compliance Tag', default='none', ) # ===== Sub 12d — per-recipe configurability ============================= collect = fields.Boolean( string='Collect This Measurement', default=True, help='Toggle off to skip this prompt at runtime without deleting ' 'it. Recipe authors use this to opt out of library-seeded ' 'prompts without affecting the library itself.', ) template_input_id = fields.Many2one( 'fp.step.template.input', string='Source Library Prompt', ondelete='set null', help='Set when this row was snapshot-copied from a library template ' 'prompt. Powers "Reset to Library Defaults" — rows where this ' 'is False are treated as recipe-only custom prompts and survive ' 'the reset.', )