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>
899 lines
35 KiB
Python
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.',
|
|
)
|