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