# -*- 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 UserError, 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' # Search by both name and code in m2o autocomplete pickers (e.g. the # Process / Recipe field on the direct-order wizard line). Without # this, typing the recipe code (e.g. "ENP-STEEL-BASIC") didn't match # because display_name composes from `name` alone for recipe roots. _rec_names_search = ['name', 'code'] # ---- 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-bathtub', 'Bathtub / Tank / Soak'), ('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'), ('fa-flag', 'Flag / Milestone / Gate'), ('fa-undo', 'Undo / Rework / Rerun'), ], 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).', ) long_running = fields.Boolean( string='Long-running step', default=False, help='When True, steps generated from this recipe node are exempt ' 'from the shop-floor auto-pause cron. Use for 24h bakes, ' 'multi-shift soaks, and similar legitimately-long operations ' 'that would otherwise be auto-paused after the idle threshold ' '(ir.config_parameter fp.shopfloor.autopause_threshold_hours, ' 'default 8h). See plan 2026-05-22-shopfloor-tablet-redesign.', ) 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). # ---- Spec-derived metadata (recipe-root only — Promote Customer Spec) ---- # These were on fp.coating.config (since retired). They describe the # PROCESS the recipe runs, not the customer-facing specification — # specs live on fusion.plating.customer.spec. phosphorus_level = fields.Selection( [('low_phos', 'Low Phosphorus (2-5%)'), ('mid_phos', 'Mid Phosphorus (6-9%)'), ('high_phos', 'High Phosphorus (10-13%)'), ('na', 'N/A')], string='Phosphorus Level', default='na', help='EN-specific. Set to N/A for non-EN processes (chrome, ' 'anodize, black oxide). Drives certificate annotation and ' 'hydrogen-embrittlement risk assessment for bake-relief.', ) thickness_min = fields.Float(string='Min Thickness', digits=(10, 4)) thickness_max = fields.Float(string='Max Thickness', digits=(10, 4)) thickness_uom = fields.Selection( [('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')], string='Thickness UoM', default='mils', ) # thickness_option_ids removed — fp.recipe.thickness model deleted. # Thickness on the SO line is now a free-text Char range (e.g. # "0.0005-0.0008 mils") that auto-fills from last-used per # (part, customer) or the part's x_fc_default_thickness_range. # ---- Bake relief — AMS 2759/9 hydrogen embrittlement (recipe root) ---- requires_bake_relief = fields.Boolean( string='Requires Bake Relief', help='Hydrogen embrittlement relief bake required (high-strength ' 'steel ≥ HRC 31 in conjunction with this chemistry). When ' 'set, finishing the job auto-creates a bake-window record ' 'and blocks shipment until bake is complete.', ) bake_window_hours = fields.Float( string='Bake Window (hours)', default=4.0, help='Maximum time between plate exit and bake start. Typical 4h ' 'per AMS 2759/9.', ) bake_temperature = fields.Float( string='Bake Temperature', default=375.0, help='Relief bake temperature. Default 375 (°F per AMS 2759/9 for ' 'steel ≥ HRC 40).', ) bake_temperature_uom = fields.Selection( [('F', '°F'), ('C', '°C')], string='Bake Temp Unit', default='F', ) bake_duration_hours = fields.Float( string='Bake Duration (hours)', default=23.0, help='Minimum bake hold time at temperature. Typical 23h.', ) # ---- NADCAP / change-control lock (recipe root) ---- # Per client direction: NADCAP-qualified recipes need manager-only # edit permission once they're checked into the system. The Word-doc # change-control workflow lives outside the ERP; this flag is the # ERP-side enforcement point. is_locked = fields.Boolean( string='Locked (Manager-Edit Only)', help='When True, only users in the Manager group can modify ' 'this recipe (or any of its child operations / steps). ' 'Use for NADCAP-qualified processes that need ' 'change-control sign-off before any edit. The flag itself ' 'can only be toggled by a manager.', ) # NB. `applicable_spec_ids` (reverse of customer.spec.recipe_ids) is # defined as an inherit in fusion_plating_quality (the module that # owns fusion.plating.customer.spec). Core can't reference it # directly without a dependency inversion. # ---- 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.', ) # Certificate Output — recipe-level cert suppression (2026-05-27 sub # docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md). # Default True for all five so existing recipes keep producing the # same cert set they produce today. A recipe author flips OFF only # the types the recipe physically never produces (passivation = no # thickness; commodity ENP = no nadcap). # # Precedence (Q1 locked decision): recipe SUPPRESSES ONLY. Customer # / part flags decide what is requested; recipe can remove from that # set but never add. See fp.job._resolve_required_cert_types. # # Surfaced on the recipe form only when node_type == 'recipe'; the # fields exist on every node row but the UX hides them deeper in # the tree to avoid confusing authors. Resolver reads from # job.recipe_id which is always a top-level recipe node. requires_coc = fields.Boolean( string='Requires CoC', default=True, help='When False, this recipe never produces a Certificate of ' 'Conformance even if the customer/part requested one.', ) requires_thickness_report = fields.Boolean( string='Requires Thickness Report', default=True, help='When False, this recipe never produces a thickness report. ' 'Use for passivation, chemical conversion, anodize seal-only, ' 'etc. — processes that physically have no plating thickness ' 'to measure.', ) requires_nadcap_cert = fields.Boolean( string='Requires Nadcap Certificate', default=True, help='When False, this recipe never auto-spawns a Nadcap cert. ' 'Use for commodity recipes that the shop does not run under ' 'Nadcap accreditation.', ) requires_mill_test = fields.Boolean( string='Requires Mill Test Report', default=True, help='When False, this recipe never auto-spawns a Mill Test Report ' 'cert.', ) requires_customer_specific = fields.Boolean( string='Requires Customer-Specific Cert', default=True, help='When False, this recipe never auto-spawns a Customer-Specific ' 'cert.', ) # Sub 14b — User-extensible Step Kinds (was Selection of 24). # 2026-05-20: required + ondelete='restrict' — kind drives gates, # workflow milestones, and operator routing. Optional was a foot-gun # (operators silently picked Generic / nothing). Pre-migrate # 19.0.20.6.0 backfills every existing row before this NOT NULL # constraint hits the schema. kind_id = fields.Many2one( 'fp.step.kind', string='Step Kind', ondelete='restrict', index=True, required=True, default=lambda self: self.env['fp.step.kind'].search( [('code', '=', 'other')], limit=1, ).id or False, help='Drives operator routing (auto-open Contract Review form / ' 'Rack assignment dialog / Bake window), customer-portal ' 'milestones (Received / Plated / Inspected / Shipped), and ' 'tablet UI (icon, station filter). Pick "Other" only when ' 'the step has no special behaviour.', ) # 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): # NADCAP / change-control lock — block writes on locked recipes # (and their descendants) for non-manager users. Manager bypass # so the lock can be toggled off. if (self and not self.env.su and not self.env.user.has_group( 'fusion_plating.group_fusion_plating_manager')): for rec in self: root = (rec if (rec.node_type == 'recipe' and not rec.parent_id) else rec.recipe_root_id) if root and root.is_locked: raise UserError(_( "Recipe '%s' is locked (NADCAP / change-control). " "Only managers can edit it. Ask a manager to " "unlock the recipe first." ) % (root.display_name or root.name or '?')) 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 ------------------------------------------------------------- # ---- Express Orders helper (2026-05-26) ---- def _fp_all_nodes_with_kind(self, kinds): """Return all descendants (and self) where default_kind ∈ kinds. Uses _parent_store's parent_path for a single SQL hit via the 'child_of' operator. No Python tree walking. :param kinds: tuple/list of default_kind strings, e.g. ('masking', 'de_masking') or ('baking',) :return: recordset of matching fusion.plating.process.node rows """ self.ensure_one() if not kinds: return self.browse([]) return self.search([ ('id', 'child_of', self.id), ('default_kind', 'in', list(kinds)), ]) 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() # ---- Auto-classify kind from name (2026-05-24, spec # docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md) ------- # Safety net: when a node's kind is the catch-all 'other' AND its # name resolves via fp_resolve_step_kind(), upgrade kind_id to the # resolved active kind. Runs on create() and on write() when name # or kind_id changes. Prevents recipe authoring + recipe # duplication from silently leaving nodes as 'other' (which then # routes them to the wrong Shop Floor column). # # Skip with context flag fp_skip_kind_autoclassify=True for admin # workflows that need to keep kind=other despite a known name. def _fp_autoclassify_kind(self): """Upgrade kind_id when current is 'other' and name resolves.""" if self.env.context.get('fp_skip_kind_autoclassify'): return from odoo.addons.fusion_plating import ( fp_resolve_step_kind, RESOLVER_KIND_TO_ACTIVE_KIND, ) Kind = self.env['fp.step.kind'] other = Kind.search([('code', '=', 'other')], limit=1) if not other: return # Cache active-kind ids by code so we don't re-search per row. kind_by_code = {} for node in self: if not node.name or node.kind_id != other: continue resolver_code = fp_resolve_step_kind(node.name) if not resolver_code: continue target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code) if not target_code: continue if target_code not in kind_by_code: tgt = Kind.search([('code', '=', target_code)], limit=1) kind_by_code[target_code] = tgt.id if tgt else False target_id = kind_by_code[target_code] if target_id: node.with_context( fp_skip_kind_autoclassify=True, ).write({'kind_id': target_id}) @api.model_create_multi def create(self, vals_list): nodes = super().create(vals_list) nodes._fp_autoclassify_kind() return nodes def write(self, vals): res = super().write(vals) if 'name' in vals or 'kind_id' in vals: self._fp_autoclassify_kind() return res # ---- 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.', )