From bef812616b1214ad66d25b14735132d368f95cdd Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 27 Apr 2026 20:30:45 -0400 Subject: [PATCH] feat(sub12a): add fp.step.template model with sane-default kind map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable step library entry. Carries the same shape fields as fusion.plating.process.node so a drag-drop snapshot is a 1:1 copy. DEFAULT_INPUTS_BY_KIND drives seeding for the 15 kinds we identified on Steelhead's job traveller (cleaning, etch, plate, bake, etc.). The seeding helper (_seed_default_inputs) is idempotent — won't duplicate inputs on repeated calls. Note: imports for the 2 child models (input + transition_input) are added in models/__init__.py here; the actual files land in the next two commits. Module won't load cleanly on entech until both ship. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating/models/__init__.py | 5 + .../fusion_plating/models/fp_step_template.py | 215 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 fusion_plating/fusion_plating/models/fp_step_template.py diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 90e6506a..4d6e6036 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -32,3 +32,8 @@ from . import fp_work_role from . import fp_proficiency from . import hr_employee from . import fp_process_node_inherit + +# Sub 12a — Simple Recipe Editor + Step Library +from . import fp_step_template +from . import fp_step_template_input +from . import fp_step_template_transition_input diff --git a/fusion_plating/fusion_plating/models/fp_step_template.py b/fusion_plating/fusion_plating/models/fp_step_template.py new file mode 100644 index 00000000..a0d3a25c --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_step_template.py @@ -0,0 +1,215 @@ +# -*- 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', + help='S14 lock — operator cannot start this step until earlier ' + 'sequenced steps are done.') + 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).') + + default_kind = fields.Selection([ + ('cleaning', 'Cleaning'), + ('etch', 'Etch'), + ('rinse', 'Rinse'), + ('plate', 'Plating'), + ('bake', 'Bake'), + ('inspect', 'Inspection'), + ('racking', 'Racking'), + ('derack', 'De-Racking'), + ('mask', 'Masking'), + ('demask', 'De-Masking'), + ('dry', 'Drying'), + ('wbf_test', 'Water Break Free Test'), + ('final_inspect', 'Final Inspection'), + ('ship', 'Shipping'), + ('gating', 'Gating'), + ], string='Step Kind', help='Drives sane-default input seeding.') + + 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 --------------------------------------------- + + DEFAULT_INPUTS_BY_KIND = { + 'cleaning': [ + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 'sec', 'sequence': 10}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': '°F', 'sequence': 20}, + ], + 'etch': [ + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 'sec', 'sequence': 10}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': '°F', 'sequence': 20}, + ], + 'rinse': [], + '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': 'Plating Thickness', 'input_type': 'thickness', + 'target_unit': 'in', 'sequence': 30}, + ], + 'bake': [ + {'name': 'Time In', 'input_type': 'text', + 'target_unit': 'HH:MM', 'sequence': 10}, + {'name': 'Time Out', 'input_type': 'text', + 'target_unit': 'HH:MM', 'sequence': 20}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': '°F', 'sequence': 30}, + ], + 'racking': [ + {'name': 'Actual Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'derack': [ + {'name': 'Actual Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'inspect': [ + {'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10}, + ], + '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': 'Actual Coating Thickness', + 'input_type': 'thickness', 'target_unit': 'in', 'sequence': 40}, + {'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50}, + ], + 'wbf_test': [ + {'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10}, + ], + 'mask': [ + {'name': 'Actual Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'demask': [], + 'dry': [], + 'ship': [ + {'name': 'Outgoing Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'gating': [], + } + + def _seed_default_inputs(self): + """Seed input_template_ids based on default_kind. Idempotent — + only adds inputs whose names don't already exist on this template.""" + Input = self.env['fp.step.template.input'] + for tpl in self: + if not tpl.default_kind: + continue + existing_names = set(tpl.input_template_ids.mapped('name')) + for spec in self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []): + if spec['name'] in existing_names: + continue + Input.create({ + 'template_id': tpl.id, + **spec, + })