feat(step-library): full plating workflow coverage + per-recipe configurability + audit

Implements 2026-04-29-step-library-audit-design.md. Bumps fusion_plating
to 19.0.18.7.0, fusion_plating_jobs to 19.0.8.12.0, fusion_plating_reports
to 19.0.10.2.0.

LIBRARY EXPANSION
- 8 new Step Kinds: Receiving, Electroclean, Strike, Salt Spray,
  Adhesion Test, Hardness Test, Packaging, Tank Replenishment
- 4 new input types: photo, multi_point_thickness, bath_chemistry_panel, ph
- DEFAULT_INPUTS_BY_KIND rewritten to seed audit-grade prompts on every
  kind (bath IDs, photos, multi-point thickness, signatures, etc.)
- + Common Audit Fields one-click button on the library template form
- Default Operator Instructions relabel + alert callout

PER-RECIPE CONFIGURABILITY
- collect (Boolean) per recipe-step input prompt — opt out without delete
- collect_measurements (Boolean) master switch on recipe step — when off,
  wizard skips entirely
- template_input_id (Many2one) traceability link from recipe to library
- Recipe-step backend form view exposes the new fields with handle drag,
  toggle, target range, and library-source column

RUNTIME WIRING
- Step input wizard filters node.input_ids to step_input AND collect=True;
  short-circuits on collect_measurements=False
- New input types: photo (image widget + ir.attachment), multi-point
  thickness (5 readings + auto avg, skips empty cells), bath chemistry
  panel (pH/conc/temp/bath bundle), pH (0-14 numeric)
- Composite values JSON-serialized into value_text; photo via attachment

CoC REPORT
- Filters captured prompts to collect=True only
- Renders new input types with appropriate format

MIGRATION (post-migrate.py for 19.0.18.7.0)
- Backfills collect=True on recipe-step inputs
- Backfills collect_measurements=True on recipe steps
- Re-runs action_seed_default_inputs on every existing template
  (idempotent, preserves user edits)
- Backfills template_input_id by name-matching against source library
  template (handles JSONB vs varchar name columns)

SEED DATA
- 8 example templates (one per new kind) in fp_step_template_data.xml
  with noupdate=1

BATTLE TEST
- bt_step_library_audit.py: 29 assertions all PASS on entech

OWL EDITOR EXTENSION DEFERRED
- The simple recipe editor's per-step Instructions/Measurements
  expansions were not implemented in this pass; users configure via the
  backend recipe-step form. Track follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-29 22:13:54 -04:00
parent bbf2476f01
commit b187192c58
34 changed files with 1690 additions and 110 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.11.0',
'version': '19.0.8.12.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -31,17 +31,21 @@ from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
# Same selection list as fp.step.template.input.input_type so authored
# rows + ad-hoc rows pick from the same vocabulary.
_FP_INPUT_TYPE_SELECTION = [
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
('photo', 'Photo'),
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
('bath_chemistry_panel', 'Bath Chemistry Panel'),
('ph', 'pH'),
]
@@ -72,11 +76,18 @@ class FpJobStepInputWizard(models.TransientModel):
return defaults
defaults['step_id'] = step.id
node = step.recipe_node_id
# Sub 12d — master switch — when off, return no input rows.
if hasattr(node, 'collect_measurements') and not node.collect_measurements:
defaults['line_ids'] = []
return defaults
# Filter to step_input prompts only — transition inputs go on the
# Move wizard, not here.
# Move wizard, not here. Also filter to collect=True (per-recipe
# opt-out, default True).
inputs = node.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
if 'collect' in inputs._fields:
inputs = inputs.filtered(lambda i: i.collect)
defaults['line_ids'] = [(0, 0, {
'node_input_id': inp.id,
'name': inp.name,
@@ -119,6 +130,7 @@ class FpJobStepInputWizard(models.TransientModel):
'moved_by_user_id': self.env.user.id,
})
ValueModel = self.env['fp.job.step.move.input.value']
Attachment = self.env['ir.attachment']
captured = 0
for line in self.line_ids:
if not line._has_value():
@@ -131,6 +143,33 @@ class FpJobStepInputWizard(models.TransientModel):
'value_boolean': line.value_boolean,
'value_date': line.value_date or False,
}
# Sub 12d — composite + photo input types serialise differently.
if line.is_photo_type and line.photo_value:
att = Attachment.create({
'name': line.photo_filename or 'photo.jpg',
'datas': line.photo_value,
'res_model': 'fp.job.step.move',
'res_id': move.id,
})
vals['value_attachment_id'] = att.id
elif line.is_multi_point_type:
import json
pts = [line.point_1, line.point_2, line.point_3,
line.point_4, line.point_5]
non_empty = [p for p in pts if p]
avg = sum(non_empty) / len(non_empty) if non_empty else 0.0
vals['value_text'] = json.dumps({
'readings': pts, 'avg': avg,
})
vals['value_number'] = avg
elif line.is_panel_type:
import json
vals['value_text'] = json.dumps({
'ph': line.panel_ph,
'concentration': line.panel_concentration,
'temperature': line.panel_temperature,
'bath_id': line.panel_bath_id or '',
})
# For ad-hoc rows (no node_input_id), preserve the operator's
# typed prompt label in value_text so the chronological CoC
# report still shows what was measured. Format: "Prompt: value"
@@ -203,6 +242,35 @@ class FpJobStepInputWizardLine(models.TransientModel):
value_boolean = fields.Boolean(string='Yes/No')
value_date = fields.Datetime(string='Date / Time')
# Sub 12d — composite + photo input types
photo_value = fields.Binary(string='Photo', attachment=True)
photo_filename = fields.Char(string='Photo Filename')
point_1 = fields.Float(string='R1')
point_2 = fields.Float(string='R2')
point_3 = fields.Float(string='R3')
point_4 = fields.Float(string='R4')
point_5 = fields.Float(string='R5')
point_avg = fields.Float(
string='Average',
compute='_compute_point_avg',
store=False,
)
panel_ph = fields.Float(string='Panel pH')
panel_concentration = fields.Float(string='Panel Concentration')
panel_temperature = fields.Float(string='Panel Temperature')
panel_bath_id = fields.Char(string='Panel Bath ID')
@api.depends('point_1', 'point_2', 'point_3', 'point_4', 'point_5')
def _compute_point_avg(self):
for rec in self:
pts = [
p for p in (
rec.point_1, rec.point_2, rec.point_3,
rec.point_4, rec.point_5,
) if p
]
rec.point_avg = sum(pts) / len(pts) if pts else 0.0
is_authored = fields.Boolean(
compute='_compute_is_authored',
help='True when this row originated from an authored recipe input. '
@@ -233,20 +301,39 @@ class FpJobStepInputWizardLine(models.TransientModel):
compute='_compute_type_flags',
)
is_photo_type = fields.Boolean(compute='_compute_type_flags')
is_multi_point_type = fields.Boolean(compute='_compute_type_flags')
is_panel_type = fields.Boolean(compute='_compute_type_flags')
@api.depends('input_type')
def _compute_type_flags(self):
numeric_types = {
'number', 'temperature', 'thickness',
'time_seconds',
'time_seconds', 'ph',
}
for rec in self:
it = rec.input_type or 'text'
rec.is_boolean_type = it in ('boolean', 'pass_fail')
rec.is_date_type = it == 'date'
rec.is_numeric_type = it in numeric_types
rec.is_photo_type = it == 'photo'
rec.is_multi_point_type = it == 'multi_point_thickness'
rec.is_panel_type = it == 'bath_chemistry_panel'
def _has_value(self):
self.ensure_one()
if self.is_photo_type:
return bool(self.photo_value)
if self.is_multi_point_type:
return any([
self.point_1, self.point_2, self.point_3,
self.point_4, self.point_5,
])
if self.is_panel_type:
return any([
self.panel_ph, self.panel_concentration,
self.panel_temperature, self.panel_bath_id,
])
return any([
self.value_text,
self.value_number,

View File

@@ -22,6 +22,9 @@
<field name="is_boolean_type" column_invisible="1"/>
<field name="is_date_type" column_invisible="1"/>
<field name="is_numeric_type" column_invisible="1"/>
<field name="is_photo_type" column_invisible="1"/>
<field name="is_multi_point_type" column_invisible="1"/>
<field name="is_panel_type" column_invisible="1"/>
<field name="name"
string="Measurement"
readonly="is_authored"
@@ -33,15 +36,6 @@
string="Unit"
readonly="is_authored"
optional="show"/>
<!-- Distinct column labels so the operator
reads which input matches the row's
type. List-view columns are static in
Odoo — labelling each by its purpose
removes the "four identical Value
columns" guesswork from the previous
layout. Only the cell matching the
row's type stays editable; others sit
blank. -->
<field name="value_number"
string="Number"
invisible="not is_numeric_type"/>
@@ -54,7 +48,33 @@
invisible="not is_date_type"/>
<field name="value_text"
string="Text"
invisible="is_numeric_type or is_boolean_type or is_date_type"/>
invisible="is_numeric_type or is_boolean_type or is_date_type or is_photo_type or is_multi_point_type or is_panel_type"/>
<field name="photo_value"
string="Photo"
widget="image"
options="{'preview_image': 'photo_value'}"
invisible="not is_photo_type"/>
<field name="photo_filename" column_invisible="1"/>
<field name="point_1" string="R1"
invisible="not is_multi_point_type" optional="show"/>
<field name="point_2" string="R2"
invisible="not is_multi_point_type" optional="show"/>
<field name="point_3" string="R3"
invisible="not is_multi_point_type" optional="show"/>
<field name="point_4" string="R4"
invisible="not is_multi_point_type" optional="hide"/>
<field name="point_5" string="R5"
invisible="not is_multi_point_type" optional="hide"/>
<field name="point_avg" string="Avg" readonly="1"
invisible="not is_multi_point_type"/>
<field name="panel_ph" string="pH"
invisible="not is_panel_type"/>
<field name="panel_concentration" string="Conc"
invisible="not is_panel_type"/>
<field name="panel_temperature" string="Temp"
invisible="not is_panel_type"/>
<field name="panel_bath_id" string="Bath"
invisible="not is_panel_type"/>
<field name="target_min" optional="hide"/>
<field name="target_max" optional="hide"/>
</list>