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

@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
"""Battle test — Step Library audit expansion (Sub 12d).
Run via odoo-shell on entech:
cat bt_step_library_audit.py | ssh pve-worker5 "pct exec 111 -- bash -c \\
'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'"
Asserts properties of the new architecture and prints PASS/FAIL.
"""
NEW_KINDS = [
'receiving', 'electroclean', 'strike', 'salt_spray',
'adhesion_test', 'hardness_test', 'packaging', 'replenishment',
]
results = []
def check(idx, name, condition, detail=''):
status = 'PASS' if condition else 'FAIL'
results.append((idx, name, status, detail))
print('[%s] #%-2d %s -- %s' % (status, idx, name, detail))
Template = env['fp.step.template']
Node = env['fusion.plating.process.node']
NodeInput = env['fusion.plating.process.node.input']
# 1. Every new Step Kind has at least 1 seed template loaded
for kind in NEW_KINDS:
cnt = Template.search_count([('default_kind', '=', kind)])
check(1, 'seed template for kind %s' % kind, cnt >= 1,
'%d found' % cnt)
# 2. New input types reachable from the library Selection
itypes = dict(Template._fields['default_kind'].selection)
all_kinds_present = all(k in itypes for k in NEW_KINDS)
check(2, 'all 8 new kinds in Selection', all_kinds_present,
'kinds=%d total in selection' % len(itypes))
# 3. fp.step.template.input has the 4 new input_type entries
ti = dict(env['fp.step.template.input']._fields['input_type'].selection)
new_types_present = all(t in ti for t in
['photo', 'multi_point_thickness',
'bath_chemistry_panel', 'ph'])
check(3, 'library input has 4 new types', new_types_present,
'%d total types' % len(ti))
# 4. Recipe-node input has the 4 new input_type entries
ni = dict(NodeInput._fields['input_type'].selection)
new_types_in_node = all(t in ni for t in
['photo', 'multi_point_thickness',
'bath_chemistry_panel', 'ph'])
check(4, 'recipe-node input has 4 new types', new_types_in_node,
'%d total types' % len(ni))
# 5. collect + collect_measurements + template_input_id fields exist
check(5, 'collect on node-input', 'collect' in NodeInput._fields,
'present' if 'collect' in NodeInput._fields else 'missing')
check(6, 'collect_measurements on node', 'collect_measurements' in Node._fields,
'present')
check(7, 'template_input_id on node-input', 'template_input_id' in NodeInput._fields,
'present')
# 8. action_seed_default_inputs is idempotent + preserves edits
tpl = Template.create({
'name': 'BT-SeedIdem-%s' % env.cr.now(),
'default_kind': 'plate',
})
tpl.action_seed_default_inputs()
n1 = len(tpl.input_template_ids)
# user edit
tpl.input_template_ids[0].name = 'EDITED-DO-NOT-CLOBBER'
tpl.action_seed_default_inputs()
n2 = len(tpl.input_template_ids)
edited = tpl.input_template_ids.filtered(
lambda i: i.name == 'EDITED-DO-NOT-CLOBBER'
)
check(8, 'seed idempotent + preserves edits',
n1 <= n2 and len(edited) == 1,
'before=%d after=%d edited_kept=%s' % (n1, n2, bool(edited)))
tpl.unlink()
# 9. action_add_common_audit_fields is idempotent
tpl = Template.create({
'name': 'BT-AuditIdem-%s' % env.cr.now(),
'default_kind': 'plate',
})
tpl.action_add_common_audit_fields()
m1 = len(tpl.input_template_ids)
tpl.action_add_common_audit_fields()
m2 = len(tpl.input_template_ids)
check(9, 'common audit fields idempotent', m1 == m2,
'first=%d second=%d' % (m1, m2))
tpl.unlink()
# 10. collect=True is default on new node-inputs
node = Node.create({
'name': 'BT-CollectDefault',
'node_type': 'step',
})
ni = NodeInput.create({
'node_id': node.id,
'name': 'BT-Prompt',
'input_type': 'text',
'kind': 'step_input',
})
check(10, 'collect default=True on new node-input', ni.collect,
'collect=%s' % ni.collect)
# 11. collect_measurements=True default on new node
check(11, 'collect_measurements default=True on new node',
node.collect_measurements,
'collect_measurements=%s' % node.collect_measurements)
node.unlink()
# 12. Wizard filter excludes collect=False rows (simulated)
node = Node.create({'name': 'BT-Filter', 'node_type': 'step'})
ni_on = NodeInput.create({
'node_id': node.id, 'name': 'On', 'input_type': 'text',
'kind': 'step_input', 'collect': True,
})
ni_off = NodeInput.create({
'node_id': node.id, 'name': 'Off', 'input_type': 'text',
'kind': 'step_input', 'collect': False,
})
visible = node.input_ids.filtered(
lambda i: i.kind == 'step_input' and i.collect
)
check(12, 'wizard filter excludes collect=False',
ni_off not in visible and ni_on in visible,
'%d/%d visible' % (len(visible), len(node.input_ids)))
# 13. Master switch path — when False, filter returns empty
node.collect_measurements = False
empty_path = (not node.collect_measurements)
check(13, 'master collect_measurements=False short-circuits',
empty_path, 'master=False')
node.unlink()
# 14. Multi-point thickness average compute (unit math, no DB)
class _Stub:
def __init__(self, *vals):
self.point_1, self.point_2, self.point_3, \
self.point_4, self.point_5 = vals
non_empty = [v for v in vals if v]
self.point_avg = sum(non_empty) / len(non_empty) if non_empty else 0
s = _Stub(0.001, 0.0012, 0.0011, 0, 0)
check(14, 'multi-point avg skips empties',
round(s.point_avg, 5) == 0.0011,
'avg=%.5f' % s.point_avg)
# 15. Sample DEFAULT_INPUTS_BY_KIND payload present for each new kind
for kind in NEW_KINDS:
seeded = Template.DEFAULT_INPUTS_BY_KIND.get(kind, [])
check(15, 'defaults dict has entries for %s' % kind,
len(seeded) >= 1,
'%d default prompts' % len(seeded))
# Summary
total = len(results)
passed = sum(1 for r in results if r[2] == 'PASS')
failed = sum(1 for r in results if r[2] == 'FAIL')
print('\n=== %d / %d PASSED -- %d FAILED ===' % (passed, total, failed))
env.cr.commit()