Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_process_node.py
gsinghpal 152ed86c3a feat(thickness): single Char range field — drop fp.recipe.thickness picker
Per client direction: every order is a thickness RANGE (e.g.
"0.0005-0.0008 mils" or "5-10 mils"), never a single value. The
old picker model (fp.recipe.thickness with a single 'value' Float)
was modelling the wrong concept and overcrowding the order entry
UI. Replaced with one free-text Char field that auto-fills from
last-used or part default.

DELETED entirely:
- fp.recipe.thickness model (file + view + ACL + manifest entry)
- recipe.thickness_option_ids One2many (the picker source)
- "Thickness Options" inline list on the recipe form
- sale.order.line.x_fc_thickness_id (M2O picker)
- account.move.line.x_fc_thickness_id
- fp.delivery.x_fc_thickness_id
- fp.direct.order.line.thickness_id

ADDED:
- sale.order.line.x_fc_thickness_range (Char) — operator types range
- account.move.line.x_fc_thickness_range — for invoice rendering
- fp.delivery.x_fc_thickness_range — for packing slip
- fp.direct.order.line.thickness_range — for the wizard
- fp.part.catalog.x_fc_default_thickness_range — part default

AUTO-FILL CHAIN (sale.order.line + wizard line):
1. Operator already typed → keep
2. Most recent SO line for (this part, this customer) with a
   non-empty thickness_range → copy that
3. part.x_fc_default_thickness_range → copy
4. Blank — operator types

Implemented as both an @api.onchange (interactive) AND a
create() override (programmatic — wizard, sale_mrp bridge,
imports). Same logic in both paths.

WIZARD push-to-defaults: when "Save as Default" toggle is ticked
on a wizard line, persist the line's thickness_range to
part.x_fc_default_thickness_range so future first-customer orders
get a sensible starting point.

REPORTS: customer_line_header.xml + report_fp_wo_sticker.xml now
print the Char range as-typed (no display_name lookup needed).

KEPT (admin documentation only — doesn't affect order entry):
- recipe.thickness_min, thickness_max, thickness_uom on the recipe
  root: documents the recipe's CAPABILITY range. No UI gate; just
  for spec authors to record what the chemistry can produce.

JOB GROUPING: fp.job auto-create groups SO lines by (recipe, part,
spec, thickness, serial). Updated to key on the thickness_range
Char (stripped) instead of the deleted thickness_id integer.

DB cleanup: --update=base ran on the upgrade, dropping the
fp_recipe_thickness table + the four x_fc_thickness_id columns.
Existing data was already nulled in earlier dev work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:54:40 -04:00

899 lines
35 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 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'
# ---- 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).
# ---- 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.',
)
# 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):
# 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 -------------------------------------------------------------
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.',
)