feat(sub12a): add fp.step.template model with sane-default kind map

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-27 20:30:45 -04:00
parent 7e98b48c01
commit bef812616b
2 changed files with 220 additions and 0 deletions

View File

@@ -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

View File

@@ -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,
})