Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_step_template.py
gsinghpal 586f05d567 chnages
2026-05-04 02:14:34 -04:00

446 lines
21 KiB
Python

# -*- 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).
kind_id = fields.Many2one(
'fp.step.kind', string='Step Kind', ondelete='restrict',
index=True, tracking=True,
help='Pick from the catalog or create a new kind. Drives sane-'
'default input seeding.',
)
# 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,
})