Operator-reported foot-gun: Step Kind dropdown had 24 options, most
of which were visual-only (cleaning, electroclean, etch, rinse,
strike, dry, wbf_test, hardness_test, adhesion_test, salt_spray,
packaging, etc.) and didn't drive any gate or milestone. Picking the
wrong one meant nothing happened; picking Generic (left default)
meant nothing happened. Authors couldn't tell which choice mattered.
Curation: 24 → 11 active kinds. Each remaining kind has a concrete
downstream behaviour (gate, portal milestone, hardware tie-in, or
"explicitly no behaviour" for Other):
other Other (catch-all, default — no special behaviour)
receiving Received portal milestone
contract_review QA-005 form gate + button_finish lock
racking Rack-assignment dialog + button_finish lock
mask Visual mask kind (covers Masking + De-Masking)
wet_process Visual wet kind (NEW, covers cleaning, rinse,
etch, strike, dry, electroclean, wbf_test)
plate Plated portal milestone (last plate step closes)
bake Bake-window state machine + Baked milestone
inspect Intermediate inspection milestone
final_inspect Inspected (terminal) portal milestone
ship Shipped milestone (back-compat; delivery-state
driven is preferred)
Retired kinds (active=False, hidden from dropdown): cleaning,
electroclean, etch, rinse, strike, dry, wbf_test, demask, derack,
replenishment, hardness_test, adhesion_test, salt_spray, packaging,
gating. Kept in DB for audit / history but not selectable.
Mandatory enforcement:
- fp.step.kind_id on fusion.plating.process.node and fp.step.template
is now required=True with ondelete='restrict' and a default that
resolves to the 'other' kind. Existing NULL rows are backfilled by
the pre-migrate before the NOT NULL constraint hits the schema.
- Dropdown no longer offers a blank / "Generic" option. New steps
land on 'other' instead of NULL.
Admin-only catalog:
- /fp/simple_recipe/kinds/create endpoint now refuses requests from
non-managers (group_fusion_plating_manager). Returns a clear
message explaining why ("each kind drives gates / milestones /
routing — pick Other if none fits, or ask a manager to wire up a
new kind").
- "+ Add a new kind…" sentinel option in the library form is hidden
unless state.recipe.user_is_manager. Backend gate is the authority;
the UI hide is just to stop showing a button that will error.
- The Step Type dropdown in the inline step-edit panel switched from
a 24-line hard-coded XML option list to a t-foreach over
state.kindOptions (the same kinds/list endpoint payload). One
source of truth — retire / add a kind in the catalog and every
picker reflects the change.
Migration impact (entech): 5 templates + 579 nodes backfilled via
name-match heuristic. 15 kinds flipped to active=False. Distribution
of the 579 backfilled nodes:
racking 105, other 97, bake 91, wet_process 90, mask 74,
inspect 44, plate 32, final_inspect 25, receiving 10,
contract_review 9, ship 2.
Drive-by:
- Migration uses _ensure_kind() that also registers ir.model.data
for the new xmlids so the subsequent data XML load doesn't create
duplicate kind records.
- Stored related default_kind on fusion.plating.process.node /
fp.step.template is written alongside kind_id in every SQL UPDATE
so legacy `node.default_kind == 'foo'` comparisons stay accurate
(the ORM doesn't recompute stored related fields after direct
SQL writes).
Module: fusion_plating 19.0.20.5.0 → 19.0.20.6.0.
15 existing tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
453 lines
22 KiB
Python
453 lines
22 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).
|
|
# 2026-05-20: required — same rationale as on fusion.plating.process.node
|
|
# (kind drives every downstream gate / milestone / routing decision).
|
|
kind_id = fields.Many2one(
|
|
'fp.step.kind', string='Step Kind', ondelete='restrict',
|
|
index=True, tracking=True,
|
|
required=True,
|
|
default=lambda self: self.env['fp.step.kind'].search(
|
|
[('code', '=', 'other')], limit=1,
|
|
).id or False,
|
|
help='Drives sane-default input seeding plus downstream gates / '
|
|
'milestones / routing when authors instantiate the template. '
|
|
'Pick "Other" only when the step has no special behaviour.',
|
|
)
|
|
# 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,
|
|
})
|