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:
@@ -32,3 +32,8 @@ from . import fp_work_role
|
|||||||
from . import fp_proficiency
|
from . import fp_proficiency
|
||||||
from . import hr_employee
|
from . import hr_employee
|
||||||
from . import fp_process_node_inherit
|
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
|
||||||
|
|||||||
215
fusion_plating/fusion_plating/models/fp_step_template.py
Normal file
215
fusion_plating/fusion_plating/models/fp_step_template.py
Normal 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,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user