# -*- 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 class FpStepTemplate(models.Model): """Reusable step template for the Simple Recipe Editor. A library entry the recipe author can drag into a recipe. Snapshot- copied at drag time — editing the template later does NOT change recipes already built. Carries the same shape fields as the runtime `fusion.plating.process.node` so a snapshot copy is a 1:1 field transfer. """ _name = 'fp.step.template' _description = 'Fusion Plating — Step Library Template' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'sequence, name' name = fields.Char(string='Title', required=True, translate=True, tracking=True) code = fields.Char(string='Code', tracking=True, help='Optional short identifier. Auto-uppercased.') description = fields.Html(string='Instructions', help='Rich-text instructions / Work-Instruction reference.') icon = fields.Selection( selection='_get_icon_selection', string='Icon', default='fa-cog', ) sequence = fields.Integer(string='Sequence', default=10) active = fields.Boolean(string='Active', default=True) company_id = fields.Many2one( 'res.company', string='Company', default=lambda self: self.env.company, ) tank_ids = fields.Many2many( 'fusion.plating.tank', string='Allowed Stations', help='Stations (tanks) this step can be performed at. The ' 'operator picks one of these at runtime.', ) process_type_id = fields.Many2one( 'fusion.plating.process.type', string='Process Type', ondelete='set null', ) material_callout = fields.Char(string='Material Callout', help='Short string printed in the traveller "Material" column. ' 'e.g. "MID PHOS". 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_signoff = fields.Boolean(string='Require QA Sign-off') requires_predecessor_done = fields.Boolean( string='Require Predecessor Done (legacy)', help='Legacy per-step opt-in for predecessor enforcement. Recipes ' 'now default to Enforce Sequential Order — use Parallel ' 'Start instead when you want a step to run alongside others.', ) parallel_start = fields.Boolean( string='Parallel Start', help='Sub 13. When this template lands inside a sequential ' 'recipe, the resulting step can be started while ' 'earlier-sequence steps are still in progress (e.g. ' 'paperwork that runs alongside production).', ) # Sub 14 — triggers_workflow_state_id is declared via _inherit in # fusion_plating_jobs/models/fp_job.py. It can't live here because # the target model (fp.job.workflow.state) is defined in jobs, and # core can't depend on jobs (cyclic dependency). requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment', help='Triggers Rack Parts sub-dialog at runtime (Sub 12b).') requires_transition_form = fields.Boolean(string='Requires Transition Form', help='Opens the transition form before Mark Done (Sub 12b).') # Sub 14b — User-extensible Step Kinds (was Selection of 24). # 2026-05-20: required — same rationale as on fusion.plating.process.node # (kind drives every downstream gate / milestone / routing decision). kind_id = fields.Many2one( 'fp.step.kind', string='Step Kind', ondelete='restrict', index=True, tracking=True, required=True, default=lambda self: self.env['fp.step.kind'].search( [('code', '=', 'other')], limit=1, ).id or False, help='Drives sane-default input seeding plus downstream gates / ' 'milestones / routing when authors instantiate the template. ' 'Pick "Other" only when the step has no special behaviour.', ) # Back-compat shim — every legacy `tpl.default_kind == "cleaning"` # call site keeps working without a refactor. Stored=True so existing # search domains [('default_kind', '=', 'cleaning')] still hit an # indexed column. default_kind = fields.Char( related='kind_id.code', store=True, readonly=True, index=True, string='Step Kind Code', ) input_template_ids = fields.One2many( 'fp.step.template.input', 'template_id', string='Operation Measurements', copy=True, ) transition_input_ids = fields.One2many( 'fp.step.template.transition.input', 'template_id', string='Transition Form Fields', copy=True, ) @api.model def _get_icon_selection(self): # Reuse the 24-icon list from fusion.plating.process.node so the # library matches whatever the tree editor offers. node = self.env['fusion.plating.process.node'] return node._fields['icon'].selection _sql_constraints = [ ('fp_step_template_code_company_uniq', 'unique(code, company_id)', 'Step template code must be unique within a company.'), ] @api.model_create_multi def create(self, vals_list): for v in vals_list: if v.get('code'): v['code'] = v['code'].upper().strip() return super().create(vals_list) def write(self, vals): if vals.get('code'): vals['code'] = vals['code'].upper().strip() return super().write(vals) # ----- Sane defaults seeding --------------------------------------------- # Sub 14b — moved from a Python dict into seeded fp.step.kind records # so users can add new kinds + their default inputs through the # standard UI. The dict below is preserved as a fallback only for # codes that don't have a matching kind_id record (legacy data after # migration). It will be removed in a future version. DEFAULT_INPUTS_BY_KIND = { 'receiving': [ {'name': 'Qty Received', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10, 'required': True}, {'name': 'Qty Rejected', 'input_type': 'number', 'target_unit': 'each', 'sequence': 20}, {'name': 'Customer PO# Verified', 'input_type': 'boolean', 'sequence': 30}, {'name': 'Packing Slip #', 'input_type': 'text', 'sequence': 40}, {'name': 'Condition Notes', 'input_type': 'text', 'sequence': 50}, {'name': 'Damage Photo', 'input_type': 'photo', 'sequence': 60}, {'name': 'Inspector Initials', 'input_type': 'signature', 'sequence': 70, 'required': True}, ], 'cleaning': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 30}, {'name': 'Ultrasonic On', 'input_type': 'boolean', 'sequence': 40}, {'name': 'Titration Done', 'input_type': 'boolean', 'sequence': 50}, ], 'electroclean': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Amperage', 'input_type': 'number', 'sequence': 30, 'hint': 'A'}, {'name': 'Voltage', 'input_type': 'number', 'sequence': 40, 'hint': 'V'}, {'name': 'Current Density', 'input_type': 'number', 'sequence': 50, 'hint': 'ASF (A per sq ft)'}, {'name': 'Polarity', 'input_type': 'selection', 'sequence': 60, 'selection_options': 'anodic,cathodic,periodic'}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 70}, ], 'etch': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Acid Concentration', 'input_type': 'number', 'sequence': 30, 'hint': '% or g/L'}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 40}, {'name': 'HE Risk Flag', 'input_type': 'boolean', 'sequence': 50, 'hint': 'Hydrogen Embrittlement risk for high-strength steel'}, ], 'rinse': [ {'name': 'Rinse Type', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'cascade,spray,DI,city'}, {'name': 'Conductivity', 'input_type': 'number', 'sequence': 20, 'hint': 'µS/cm — required for DI rinses'}, {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 30}, ], 'strike': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Amperage', 'input_type': 'number', 'sequence': 30, 'hint': 'A'}, {'name': 'Voltage', 'input_type': 'number', 'sequence': 40, 'hint': 'V'}, {'name': 'Current Density', 'input_type': 'number', 'sequence': 50, 'hint': 'ASF'}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 60}, ], 'plate': [ {'name': 'Actual Time', 'input_type': 'time_hms', 'target_unit': 'min', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 30}, {'name': 'pH', 'input_type': 'ph', 'sequence': 40}, {'name': 'Bath Concentration', 'input_type': 'number', 'sequence': 50, 'hint': 'g/L'}, {'name': 'Current Density', 'input_type': 'number', 'sequence': 60, 'hint': 'ASF — electroplate only'}, {'name': 'Plating Thickness', 'input_type': 'multi_point_thickness', 'target_unit': 'in', 'sequence': 70}, ], 'replenishment': [ {'name': 'Bath ID', 'input_type': 'text', 'sequence': 10, 'required': True}, {'name': 'Chemistry Added', 'input_type': 'text', 'sequence': 20, 'hint': 'name + amount, e.g. "Nickel sulfamate 500mL"'}, {'name': 'pH Before', 'input_type': 'ph', 'sequence': 30}, {'name': 'pH After', 'input_type': 'ph', 'sequence': 40}, {'name': 'Concentration Before', 'input_type': 'number', 'sequence': 50}, {'name': 'Concentration After', 'input_type': 'number', 'sequence': 60}, {'name': 'Operator Initials', 'input_type': 'signature', 'sequence': 70, 'required': True}, ], 'wbf_test': [ {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10, 'required': True}, {'name': 'Retest Count', 'input_type': 'number', 'sequence': 20}, {'name': 'Photo on FAIL', 'input_type': 'photo', 'sequence': 30}, ], 'dry': [ {'name': 'Dry Method', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'hot air,oven,spin'}, {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 20}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 30}, ], 'bake': [ {'name': 'Time In', 'input_type': 'date', 'sequence': 10}, {'name': 'Time Out', 'input_type': 'date', 'sequence': 20}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 30}, {'name': 'Oven ID', 'input_type': 'text', 'sequence': 40}, {'name': 'Chart Recorder File', 'input_type': 'photo', 'sequence': 50, 'hint': 'Attach AMS-2759 chart-recorder file'}, ], 'racking': [ {'name': 'Actual Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10, 'required': True}, {'name': 'Rack ID', 'input_type': 'text', 'sequence': 20}, {'name': 'Masking Applied', 'input_type': 'boolean', 'sequence': 30}, {'name': 'Photo of Racked Load', 'input_type': 'photo', 'sequence': 40}, ], 'derack': [ {'name': 'Actual Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10}, {'name': 'Mask Removal Method', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'mechanical,solvent,thermal,not applicable'}, {'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 30}, ], 'mask': [ {'name': 'Actual Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10}, {'name': 'Mask Material', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'Microshield,latex tape,vinyl plugs,wax,other'}, {'name': 'Photo of Masked Parts', 'input_type': 'photo', 'sequence': 30}, ], 'demask': [ {'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 10}, {'name': 'Surface Condition', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'clean,marks,needs rework'}, ], 'inspect': [ {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10, 'required': True}, {'name': 'Defect Type', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'pitting,burn,blister,peel,missing coverage,none'}, {'name': 'Thickness Sample', 'input_type': 'thickness', 'target_unit': 'in', 'sequence': 30}, {'name': 'Photo', 'input_type': 'photo', 'sequence': 40}, {'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 50}, ], 'hardness_test': [ {'name': 'Test Load', 'input_type': 'number', 'sequence': 10, 'hint': 'gf'}, {'name': 'Readings (HV/HK/HRC)', 'input_type': 'multi_point_thickness', 'sequence': 20, 'hint': 'Three indents minimum'}, {'name': 'Equipment ID', 'input_type': 'text', 'sequence': 30}, {'name': 'Last Calibration Date', 'input_type': 'date', 'sequence': 40}, ], 'adhesion_test': [ {'name': 'Test Method', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'bend,tape,burnish,file'}, {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20, 'required': True}, {'name': 'Photo of Coupon', 'input_type': 'photo', 'sequence': 30}, ], 'salt_spray': [ {'name': 'Test Duration', 'input_type': 'number', 'sequence': 10, 'hint': 'hours'}, {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20, 'required': True}, {'name': 'Red Rust %', 'input_type': 'number', 'sequence': 30}, {'name': 'White Corrosion %', 'input_type': 'number', 'sequence': 40}, {'name': 'Lab Report', 'input_type': 'photo', 'sequence': 50, 'hint': 'Attach scanned lab report'}, ], 'final_inspect': [ {'name': 'Outgoing Part Count Verified', 'input_type': 'boolean', 'sequence': 10}, {'name': 'Qty Accepted', 'input_type': 'number', 'target_unit': 'each', 'sequence': 20}, {'name': 'Qty Rejected', 'input_type': 'number', 'target_unit': 'each', 'sequence': 30}, {'name': 'Defect Categorization', 'input_type': 'selection', 'sequence': 35, 'selection_options': 'pitting,burn,blister,peel,missing coverage,dimensional,none'}, {'name': 'Actual Coating Thickness', 'input_type': 'multi_point_thickness', 'target_unit': 'in', 'sequence': 40}, {'name': 'Dimensional Verification', 'input_type': 'pass_fail', 'sequence': 45}, {'name': 'Surface Finish (Ra)', 'input_type': 'number', 'sequence': 47, 'hint': 'µin'}, {'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50, 'required': True}, {'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 60}, ], 'packaging': [ {'name': 'Packaging Type', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'VCI bag,bubble wrap,separator paper,custom crate,other'}, {'name': 'Qty Per Package', 'input_type': 'number', 'target_unit': 'each', 'sequence': 20}, {'name': 'Package Count', 'input_type': 'number', 'sequence': 30}, {'name': 'Cert Package Included', 'input_type': 'boolean', 'sequence': 40}, {'name': 'Customer-Supplied Packaging', 'input_type': 'boolean', 'sequence': 50}, ], 'ship': [ {'name': 'Outgoing Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10, 'required': True}, {'name': 'Carrier', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'UPS,FedEx,Purolator,Customer Pickup,Other'}, {'name': 'Tracking #', 'input_type': 'text', 'sequence': 30}, {'name': 'BoL #', 'input_type': 'text', 'sequence': 40}, {'name': 'Photo of Sealed Shipment', 'input_type': 'photo', 'sequence': 50}, ], 'gating': [], 'contract_review': [ {'name': 'Reviewer Initials', 'input_type': 'signature', 'sequence': 10}, {'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20}, {'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30}, ], } COMMON_AUDIT_FIELDS = [ {'name': 'Operator Initials', 'input_type': 'signature', 'required': True, 'sequence': 800}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 810}, {'name': 'Photo on Failure', 'input_type': 'photo', 'sequence': 820, 'hint': 'upload only if failure observed'}, {'name': 'Equipment ID', 'input_type': 'text', 'sequence': 830}, ] def action_add_common_audit_fields(self): """Idempotently append the common audit fields to this template. Skips rows whose name already exists. Logs to chatter. """ Input = self.env['fp.step.template.input'] for tpl in self: existing_names = set(tpl.input_template_ids.mapped('name')) added = [] for spec in self.COMMON_AUDIT_FIELDS: if spec['name'] in existing_names: continue Input.create({ 'template_id': tpl.id, **spec, }) added.append(spec['name']) if added: tpl.message_post( body=_('Added common audit fields: %s') % ', '.join(added), message_type='notification', subtype_xmlid='mail.mt_note', ) return True # Mapping from fp.step.kind.default.input fields → fp.step.template.input # spec dict. Keep narrow — copy only the columns both models share. _KIND_DEFAULT_INPUT_FIELDS = ( 'name', 'input_type', 'target_unit', 'required', 'hint', 'selection_options', 'sequence', ) def action_seed_default_inputs(self): """Seed input_template_ids from kind_id.default_input_ids. Idempotent — only adds inputs whose names don't already exist on this template. Falls back to the legacy DEFAULT_INPUTS_BY_KIND dict if the template has no kind_id but still carries a default_kind code (defensive — shouldn't happen post-migration). Public method (Odoo 19 requires non-underscore-prefixed names for methods called from a view button). """ Input = self.env['fp.step.template.input'] for tpl in self: existing_names = set(tpl.input_template_ids.mapped('name')) specs = [] if tpl.kind_id: for d in tpl.kind_id.default_input_ids: spec = {f: d[f] for f in self._KIND_DEFAULT_INPUT_FIELDS} specs.append(spec) elif tpl.default_kind: # Legacy fallback — kind_id never got linked. specs = self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []) for spec in specs: if spec['name'] in existing_names: continue Input.create({ 'template_id': tpl.id, **spec, })