From ec0a07fbe9cde42b2b2d906cdc67ad887fd7532b Mon Sep 17 00:00:00 2001
From: gsinghpal
Date: Wed, 29 Apr 2026 22:53:59 -0400
Subject: [PATCH] fix(audit-trail): 3 production bugs found via end-to-end
Anodize battle test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Battle-tested complete workflow on entech: ABC Manufacturing + Anodize
recipe (id=136) cloned to part-variant (id=1775) → SO S00276 confirmed →
fp.job 1234 with 17 steps → recorded 56 measurement values exercising all
13 input types (incl. all 4 new types) → CoC chronological report renders
69KB with all values incl. photo thumbnails.
Bugs found and fixed:
1. fp.process.node.input_ids missing copy=True — when a master recipe
was cloned per-part (the standard variant pattern), the operator
prompts on each step did NOT get copied to the variant. Result: jobs
built from variants ran with zero prompts even though the master had
them. Fixed: input_ids now copy=True so cloning auto-duplicates.
2. CoC chronological template read dest.input_ids where dest is
fp.job.step. Steps don't carry input_ids — that field lives on the
recipe node. Result: AttributeError aborted the entire CoC render.
Fixed: walk via dest.recipe_node_id.input_ids; preserves the existing
collect=True filter.
3. CoC chronological template used hasattr() in a t-value expression.
QWeb's expression engine doesn't expose Python builtins, raised
KeyError: 'hasattr'. Fixed: use 'collect' in i._fields instead.
Also enhanced photo rendering in CoC: was just "[Attachment]" placeholder;
now renders an actual
thumbnail (max 80px tall) plus the filename.
Battle-test script saved to fusion_plating/scripts/bt_e2e_anodize_v2.py
for re-runs / regression testing.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
fusion_plating/fusion_plating/__manifest__.py | 2 +-
.../controllers/simple_recipe_controller.py | 149 ++++++++
.../fusion_plating/models/fp_process_node.py | 1 +
.../fusion_plating/scripts/bt_e2e_anodize.py | 347 ++++++++++++++++++
.../scripts/bt_e2e_anodize_v2.py | 325 ++++++++++++++++
.../fusion_plating/scripts/bt_e2e_recon.py | 56 +++
.../fusion_plating/scripts/bt_e2e_recon2.py | 49 +++
.../fusion_plating/scripts/bt_e2e_recon3.py | 49 +++
.../fusion_plating/scripts/bt_e2e_recon4.py | 46 +++
.../static/src/js/simple_recipe_editor.js | 77 ++++
.../static/src/scss/simple_recipe_editor.scss | 21 ++
.../static/src/xml/simple_recipe_editor.xml | 114 ++++++
.../report/report_coc_chronological.xml | 13 +-
13 files changed, 1246 insertions(+), 3 deletions(-)
create mode 100644 fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py
create mode 100644 fusion_plating/fusion_plating/scripts/bt_e2e_anodize_v2.py
create mode 100644 fusion_plating/fusion_plating/scripts/bt_e2e_recon.py
create mode 100644 fusion_plating/fusion_plating/scripts/bt_e2e_recon2.py
create mode 100644 fusion_plating/fusion_plating/scripts/bt_e2e_recon3.py
create mode 100644 fusion_plating/fusion_plating/scripts/bt_e2e_recon4.py
diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index 65d6b435..93e9cd03 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
- 'version': '19.0.18.7.0',
+ 'version': '19.0.18.7.3',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
index d497c2b5..bac67319 100644
--- a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
+++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
@@ -62,6 +62,26 @@ class SimpleRecipeController(http.Controller):
}
def _step_payload(self, step):
+ # Sub 12d — measurement prompts. Filter to step_input only (transition
+ # prompts live on the move dialog). Sort by sequence so the editor
+ # renders them in author order.
+ step_inputs = step.input_ids.filtered(
+ lambda i: (i.kind or 'step_input') == 'step_input'
+ ).sorted('sequence')
+ total = len(step_inputs)
+ on = sum(1 for i in step_inputs if getattr(i, 'collect', True))
+ if total == 0:
+ badge_text = 'No measurements'
+ badge_class = 'bg-secondary'
+ elif not step.collect_measurements:
+ badge_text = 'Off'
+ badge_class = 'bg-secondary'
+ elif on == total:
+ badge_text = '%d/%d collected' % (on, total)
+ badge_class = 'bg-success'
+ else:
+ badge_text = '%d/%d collected' % (on, total)
+ badge_class = 'bg-warning'
return {
'id': step.id,
'name': step.name,
@@ -79,6 +99,25 @@ class SimpleRecipeController(http.Controller):
],
'work_center_id': step.work_center_id.id if step.work_center_id else False,
'source_template_id': step.source_template_id.id or False,
+ 'collect_measurements': bool(step.collect_measurements),
+ 'measurements_badge_text': badge_text,
+ 'measurements_badge_class': badge_class,
+ 'inputs': [
+ {
+ 'id': i.id,
+ 'name': i.name or '',
+ 'input_type': i.input_type or 'text',
+ 'collect': bool(getattr(i, 'collect', True)),
+ 'required': bool(i.required),
+ 'target_min': i.target_min or 0.0,
+ 'target_max': i.target_max or 0.0,
+ 'target_unit': i.target_unit or '',
+ 'sequence': i.sequence or 0,
+ 'from_library': bool(getattr(i, 'template_input_id', False)),
+ 'hint': i.hint or '',
+ }
+ for i in step_inputs
+ ],
}
# --------------------------------------------------------------- library
@@ -280,3 +319,113 @@ class SimpleRecipeController(http.Controller):
'target_unit': src_in.target_unit,
'compliance_tag': src_in.compliance_tag,
})
+
+ # ============================================================
+ # Sub 12d — per-recipe configurability endpoints
+ # ============================================================
+
+ @http.route('/fp/simple_recipe/step/toggle_collect', type='jsonrpc', auth='user')
+ def step_toggle_collect(self, node_id, collect):
+ """Master switch — toggle collect_measurements on a recipe step."""
+ node = request.env['fusion.plating.process.node'].browse(int(node_id))
+ node.check_access('write')
+ node.collect_measurements = bool(collect)
+ return {'ok': True, 'collect_measurements': node.collect_measurements}
+
+ @http.route('/fp/simple_recipe/step/edit_input', type='jsonrpc', auth='user')
+ def step_edit_input(self, input_id, payload):
+ """Edit a single recipe-step input. payload is a dict with any of:
+ collect, name, input_type, target_min, target_max, target_unit,
+ required, sequence, selection_options, hint."""
+ Input = request.env['fusion.plating.process.node.input']
+ rec = Input.browse(int(input_id))
+ if not rec.exists():
+ return {'ok': False, 'error': 'not_found'}
+ rec.node_id.check_access('write')
+ allowed = {
+ 'collect', 'name', 'input_type', 'target_min', 'target_max',
+ 'target_unit', 'required', 'sequence', 'selection_options', 'hint',
+ }
+ vals = {k: v for k, v in (payload or {}).items() if k in allowed}
+ if vals:
+ rec.write(vals)
+ return {'ok': True}
+
+ @http.route('/fp/simple_recipe/step/add_input', type='jsonrpc', auth='user')
+ def step_add_input(self, node_id, payload):
+ """Add a custom prompt to a recipe step (no template_input_id link)."""
+ node = request.env['fusion.plating.process.node'].browse(int(node_id))
+ node.check_access('write')
+ Input = request.env['fusion.plating.process.node.input']
+ existing_max = max(node.input_ids.mapped('sequence') or [0])
+ rec = Input.create({
+ 'node_id': node.id,
+ 'name': (payload or {}).get('name') or 'Custom Prompt',
+ 'input_type': (payload or {}).get('input_type') or 'text',
+ 'kind': 'step_input',
+ 'collect': True,
+ 'sequence': existing_max + 10,
+ 'required': bool((payload or {}).get('required')),
+ })
+ return {'ok': True, 'input_id': rec.id}
+
+ @http.route('/fp/simple_recipe/step/remove_input', type='jsonrpc', auth='user')
+ def step_remove_input(self, input_id):
+ """Delete a custom prompt. Library-sourced rows are protected
+ — recipe authors should toggle collect=False instead of deleting."""
+ Input = request.env['fusion.plating.process.node.input']
+ rec = Input.browse(int(input_id))
+ if not rec.exists():
+ return {'ok': False, 'error': 'not_found'}
+ rec.node_id.check_access('write')
+ if getattr(rec, 'template_input_id', False) and rec.template_input_id:
+ return {
+ 'ok': False,
+ 'error': 'library_sourced',
+ 'message': 'Toggle Collect off instead of deleting library prompts.',
+ }
+ rec.unlink()
+ return {'ok': True}
+
+ @http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user')
+ def step_reset_to_library(self, node_id):
+ """Re-sync the recipe step's input_ids + description from the linked
+ library template. Preserves rows where template_input_id=False
+ (recipe-author-added custom prompts)."""
+ Node = request.env['fusion.plating.process.node']
+ Input = request.env['fusion.plating.process.node.input']
+ node = Node.browse(int(node_id))
+ if not node.exists() or not node.source_template_id:
+ return {'ok': False, 'error': 'no_library_template'}
+ node.check_access('write')
+ tpl = node.source_template_id
+ # Drop existing rows that came from the library (template_input_id set);
+ # preserve recipe-only customs.
+ node.input_ids.filtered(
+ lambda i: getattr(i, 'template_input_id', False)
+ and i.template_input_id
+ ).unlink()
+ # Re-snapshot from library
+ for src in tpl.input_template_ids:
+ Input.create({
+ 'node_id': node.id,
+ 'template_input_id': src.id,
+ 'name': src.name,
+ 'input_type': src.input_type,
+ 'target_min': src.target_min,
+ 'target_max': src.target_max,
+ 'target_unit': src.target_unit,
+ 'required': src.required,
+ 'hint': src.hint,
+ 'sequence': src.sequence,
+ 'selection_options': src.selection_options,
+ 'kind': 'step_input',
+ 'collect': True,
+ })
+ node.description = tpl.description or False
+ node.collect_measurements = True
+ node.message_post(
+ body='Reset to library defaults from template "%s"' % tpl.name,
+ message_type='notification',
+ )
+ return {'ok': True}
diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py
index d36ff083..760c7e2b 100644
--- a/fusion_plating/fusion_plating/models/fp_process_node.py
+++ b/fusion_plating/fusion_plating/models/fp_process_node.py
@@ -292,6 +292,7 @@ class FpProcessNode(models.Model):
'fusion.plating.process.node.input',
'node_id',
string='Operator Inputs',
+ copy=True,
)
# ===== Sub 12a — Simple Editor + Step Library extensions =================
diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py
new file mode 100644
index 00000000..643cc323
--- /dev/null
+++ b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py
@@ -0,0 +1,347 @@
+# -*- coding: utf-8 -*-
+"""End-to-end battle test — full Anodize job for ABC Manufacturing.
+
+Phases:
+ A. Auto-infer default_kind on existing Anodize recipe steps + seed prompts
+ B. Create SO line + confirm (trigger job creation)
+ C. Walk every step recording values exercising every input type
+ D. Generate CoC report; verify all values rendered
+
+Reports PASS/FAIL/findings as it goes.
+"""
+
+import json
+import logging
+from odoo import fields
+_logger = logging.getLogger('bt_e2e_anodize')
+
+findings = [] # list of (severity, message)
+def find(severity, msg):
+ findings.append((severity, msg))
+ print('[%s] %s' % (severity, msg))
+
+Node = env['fusion.plating.process.node']
+NodeInput = env['fusion.plating.process.node.input']
+Template = env['fp.step.template']
+
+# ============================================================
+# Phase A — auto-seed prompts on the Anodize recipe
+# ============================================================
+print('\n========== PHASE A: seed prompts on Anodize recipe ==========\n')
+
+KIND_KEYWORDS = [
+ ('cleaning', ['solvent clean', 'alkaline clean', 'soak clean']),
+ ('etch', ['etch', 'deoxidize', 'desmut', 'activation', 'acid dip']),
+ ('rinse', ['rinse']),
+ ('plate', ['anodize', 'plat', 'e-nickel', 'enp', 'chrome']),
+ ('bake', ['bake', 'seal', 'hot water seal']),
+ ('racking', ['racking', 'rack ', 'rack)']),
+ ('derack', ['unrack', 'de-rack', 'derack']),
+ ('mask', ['masking', 'apply mask']),
+ ('demask', ['de-mask', 'demask', 'unmask']),
+ ('dry', ['dry', 'drying']),
+ ('inspect', ['inspect']),
+ ('final_inspect', ['final inspect', 'final inspection']),
+ ('wbf_test', ['water break', 'wbf']),
+ ('ship', ['ship', 'shipping']),
+ ('packaging', ['packag', 'pack ']),
+ ('contract_review', ['contract review', 'qa-005']),
+ ('receiving', ['receiving', 'incoming']),
+ ('electroclean', ['electroclean', 'electro clean']),
+ ('strike', ['strike', 'wood', 'activation strike']),
+ ('hardness_test', ['hardness']),
+ ('adhesion_test', ['adhesion']),
+ ('salt_spray', ['salt spray', 'corrosion test']),
+ ('replenishment', ['replenish', 'top-up']),
+]
+
+def infer_kind(name):
+ n = (name or '').lower()
+ for kind, kws in KIND_KEYWORDS:
+ if any(kw in n for kw in kws):
+ return kind
+ return None
+
+def collect_step_nodes(root, out):
+ if root.node_type in ('step', 'operation'):
+ out.append(root)
+ for c in root.child_ids:
+ collect_step_nodes(c, out)
+
+anodize = Node.browse(136)
+all_steps = []
+collect_step_nodes(anodize, all_steps)
+find('INFO', 'Anodize recipe has %d step/operation nodes' % len(all_steps))
+
+# 1. Set default_kind by inference on steps that lack one
+patched_kinds = 0
+for s in all_steps:
+ if not s.default_kind:
+ guess = infer_kind(s.name)
+ if guess:
+ s.default_kind = guess
+ patched_kinds += 1
+ else:
+ # Gating-style "Ready For X" steps stay without a kind
+ if 'ready' in (s.name or '').lower() or 'gating' in (s.name or '').lower():
+ s.default_kind = 'gating'
+ patched_kinds += 1
+find('INFO', 'Patched default_kind on %d steps' % patched_kinds)
+
+# 2. For each step with a kind, seed prompts onto the recipe node
+DEFAULTS = Template.DEFAULT_INPUTS_BY_KIND
+seeded_count = 0
+for s in all_steps:
+ if not s.default_kind or s.default_kind == 'gating':
+ continue
+ existing_names = set(s.input_ids.mapped('name'))
+ specs = DEFAULTS.get(s.default_kind, [])
+ for spec in specs:
+ if spec['name'] in existing_names:
+ continue
+ NodeInput.create({
+ 'node_id': s.id,
+ 'name': spec['name'],
+ 'input_type': spec.get('input_type', 'text'),
+ 'kind': 'step_input',
+ 'collect': True,
+ 'sequence': spec.get('sequence', 10),
+ 'required': spec.get('required', False),
+ 'hint': spec.get('hint', ''),
+ 'selection_options': spec.get('selection_options', ''),
+ 'target_unit': spec.get('target_unit', False),
+ })
+ seeded_count += 1
+find('INFO', 'Seeded %d prompts onto Anodize recipe steps' % seeded_count)
+env.cr.commit()
+
+# Verify
+for s in all_steps[:5]:
+ print(' %s (kind=%s) inputs=%d' % (
+ s.name, s.default_kind or '-', len(s.input_ids)
+ ))
+
+# ============================================================
+# Phase B — create SO line + confirm
+# ============================================================
+print('\n========== PHASE B: create SO + confirm ==========\n')
+
+abc = env['res.partner'].browse(943)
+part = env['fp.part.catalog'].browse(148) # ABC part 4321
+print('Customer:', abc.name)
+print('Part:', part.part_number, 'rev', part.revision)
+print('Recipe:', anodize.name, 'id=', anodize.id)
+
+# Find a product to use on the SO line. Prefer existing service product.
+prod = env['product.product'].search([], limit=1)
+
+so = env['sale.order'].create({
+ 'partner_id': abc.id,
+ 'x_fc_planned_start_date': fields.Date.today(),
+ 'x_fc_po_number': 'BT-PO-E2E-001',
+ 'client_order_ref': 'BT-PO-E2E-001',
+})
+sol = env['sale.order.line'].create({
+ 'order_id': so.id,
+ 'product_id': prod.id,
+ 'product_uom_qty': 5,
+ 'price_unit': 100.0,
+ 'name': 'BT-E2E Anodize Test Line',
+ 'x_fc_part_catalog_id': part.id,
+ 'x_fc_process_variant_id': anodize.id,
+})
+find('INFO', 'Created SO %s line %d' % (so.name, sol.id))
+
+# Confirm
+try:
+ so.action_confirm()
+ find('PASS', 'SO %s confirmed (state=%s)' % (so.name, so.state))
+except Exception as e:
+ find('FAIL', 'SO confirm raised: %s' % str(e)[:200])
+ raise SystemExit
+env.cr.commit()
+
+# Find resulting fp.job
+job = env['fp.job'].search([('origin', '=', so.name)], limit=1)
+if not job:
+ find('FAIL', 'No fp.job created from SO %s' % so.name)
+ raise SystemExit
+find('PASS', 'fp.job %d created' % job.id)
+
+job_steps = env['fp.job.step'].search([('job_id', '=', job.id)], order='sequence')
+find('INFO', 'job has %d steps' % len(job_steps))
+for js in job_steps[:5]:
+ rn = js.recipe_node_id
+ print(' step %s -- recipe_node=%d inputs=%d kind=%s' % (
+ js.name, rn.id if rn else 0,
+ len(rn.input_ids) if rn else 0,
+ rn.default_kind if rn else '-',
+ ))
+
+# ============================================================
+# Phase C — record measurements covering every input type
+# ============================================================
+print('\n========== PHASE C: record measurements ==========\n')
+
+# Pick steps that exercise different input types
+steps_to_walk = [
+ js for js in job_steps
+ if js.recipe_node_id and any(
+ i.kind == 'step_input' for i in js.recipe_node_id.input_ids
+ )
+]
+find('INFO', 'walking %d steps with prompts' % len(steps_to_walk))
+
+# Helper: simulate the wizard + commit for a single step
+SAMPLE_VALUES = {
+ 'text': 'TEST-VAL',
+ 'number': 42.5,
+ 'boolean': True,
+ 'selection': None, # filled in per-prompt from selection_options
+ 'date': fields.Datetime.now(),
+ 'signature': 'JD',
+ 'time_hms': 1800, # seconds = 30 min
+ 'time_seconds': 600,
+ 'temperature': 185.5,
+ 'thickness': 0.0012,
+ 'pass_fail': True,
+ 'photo': None, # binary attachment created on demand
+ 'multi_point_thickness': [0.0010, 0.0011, 0.0012, 0.0011, 0.0013],
+ 'bath_chemistry_panel': {'ph': 4.8, 'concentration': 18.5,
+ 'temperature': 75.0, 'bath_id': 'TANK-A1'},
+ 'ph': 4.8,
+}
+
+Move = env['fp.job.step.move']
+Value = env['fp.job.step.move.input.value']
+Att = env['ir.attachment']
+
+types_exercised = set()
+total_values = 0
+errors = []
+
+for js in steps_to_walk[:20]:
+ rn = js.recipe_node_id
+ prompts = rn.input_ids.filtered(lambda i: i.kind == 'step_input' and i.collect)
+ if not prompts:
+ continue
+ try:
+ move = Move.create({
+ 'job_id': job.id,
+ 'from_step_id': js.id,
+ 'to_step_id': js.id,
+ 'transfer_type': 'step',
+ 'qty_moved': job.qty or 1,
+ 'moved_by_user_id': env.user.id,
+ })
+ except Exception as e:
+ errors.append('Move create on step %s: %s' % (js.name, str(e)[:120]))
+ continue
+
+ for p in prompts:
+ types_exercised.add(p.input_type)
+ vals = {
+ 'move_id': move.id,
+ 'node_input_id': p.id,
+ }
+ try:
+ t = p.input_type
+ if t == 'photo':
+ # Create a tiny attachment
+ att = Att.create({
+ 'name': 'bt_photo.png',
+ 'datas': b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
+ 'res_model': 'fp.job.step.move',
+ 'res_id': move.id,
+ })
+ vals['value_attachment_id'] = att.id
+ elif t == 'multi_point_thickness':
+ pts = SAMPLE_VALUES['multi_point_thickness']
+ avg = sum(pts) / len(pts)
+ vals['value_text'] = json.dumps({'readings': pts, 'avg': avg})
+ vals['value_number'] = avg
+ elif t == 'bath_chemistry_panel':
+ vals['value_text'] = json.dumps(SAMPLE_VALUES['bath_chemistry_panel'])
+ elif t == 'ph':
+ vals['value_number'] = SAMPLE_VALUES['ph']
+ elif t == 'pass_fail' or t == 'boolean':
+ vals['value_boolean'] = True
+ elif t == 'date':
+ vals['value_date'] = fields.Datetime.now()
+ elif t == 'selection':
+ opts = (p.selection_options or '').split(',')
+ vals['value_text'] = opts[0].strip() if opts else 'option_a'
+ elif t in ('number', 'temperature', 'thickness', 'time_seconds'):
+ vals['value_number'] = SAMPLE_VALUES.get(t, 1.0)
+ elif t == 'time_hms':
+ vals['value_number'] = SAMPLE_VALUES['time_hms']
+ elif t == 'signature':
+ vals['value_text'] = SAMPLE_VALUES['signature']
+ else:
+ vals['value_text'] = SAMPLE_VALUES.get(t, 'TEST')
+ Value.create(vals)
+ total_values += 1
+ except Exception as e:
+ errors.append('Value create on prompt %s (type %s): %s' % (
+ p.name, p.input_type, str(e)[:120]
+ ))
+
+env.cr.commit()
+find('INFO', 'Recorded %d values across %d steps' % (total_values, len(steps_to_walk)))
+find('INFO', 'Exercised input types: %s' % sorted(types_exercised))
+if errors:
+ for e in errors[:10]:
+ find('FAIL', e)
+else:
+ find('PASS', 'No value-creation errors')
+
+# ============================================================
+# Phase D — verify CoC chronological can render
+# ============================================================
+print('\n========== PHASE D: render CoC report ==========\n')
+
+# Try rendering the chronological CoC for this job.
+try:
+ # The report is on fp.job (or sale.order). Let me just call the QWeb
+ # render directly to catch template errors.
+ moves = Move.search([('job_id', '=', job.id)], order='move_datetime')
+ find('INFO', '%d moves on job %d' % (len(moves), job.id))
+ # Index captured values by node_input_id
+ for mv in moves[:5]:
+ cvs = mv.transition_input_value_ids
+ print(' move id=%d step=%s captured=%d' % (
+ mv.id, mv.to_step_id.name, len(cvs)
+ ))
+
+ # Try to render the report XML
+ Report = env['ir.actions.report']
+ coc_report = env.ref(
+ 'fusion_plating_reports.action_report_coc_chronological',
+ raise_if_not_found=False,
+ )
+ if coc_report:
+ try:
+ html, _ = coc_report._render_qweb_html([job.id])
+ find('PASS', 'CoC report rendered, len=%d' % len(html or ''))
+ # Spot-check it includes a recorded value
+ html_str = (html or b'').decode('utf-8', errors='ignore') if isinstance(html, bytes) else str(html)
+ if 'TEST-VAL' in html_str or '42.5' in html_str or '0.0011' in html_str:
+ find('PASS', 'CoC report contains at least one recorded value')
+ else:
+ find('FAIL', 'CoC report rendered but no recorded value found in body')
+ except Exception as e:
+ find('FAIL', 'CoC render exception: %s' % str(e)[:200])
+ else:
+ find('FAIL', 'CoC chronological report action not found')
+except Exception as e:
+ find('FAIL', 'CoC verification step crashed: %s' % str(e)[:200])
+
+# ============================================================
+# Summary
+# ============================================================
+print('\n========== SUMMARY ==========')
+passes = sum(1 for f in findings if f[0] == 'PASS')
+fails = sum(1 for f in findings if f[0] == 'FAIL')
+infos = sum(1 for f in findings if f[0] == 'INFO')
+print('PASS=%d FAIL=%d INFO=%d' % (passes, fails, infos))
+print('SO:', so.name, 'JOB:', job.id)
diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_anodize_v2.py b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize_v2.py
new file mode 100644
index 00000000..93f58166
--- /dev/null
+++ b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize_v2.py
@@ -0,0 +1,325 @@
+# -*- coding: utf-8 -*-
+"""End-to-end battle test v2 — operates on existing job 1234.
+
+1. Seed prompts onto variant subtree (recipe id=1775)
+2. Re-link recipe nodes to job steps (recipe_node_id should point at variant nodes)
+3. Walk every job step; record every input type
+4. Render CoC chronological body via QWeb directly
+5. Print findings
+"""
+
+import json
+from odoo import fields
+
+findings = []
+def find(severity, msg):
+ findings.append((severity, msg))
+ print('[%s] %s' % (severity, msg))
+
+Node = env['fusion.plating.process.node']
+NodeInput = env['fusion.plating.process.node.input']
+Template = env['fp.step.template']
+
+# ============================================================
+# Phase A — seed prompts on the per-part variant (id=1775)
+# ============================================================
+print('\n========== PHASE A: seed prompts on variant 1775 ==========\n')
+
+KIND_KEYWORDS = [
+ ('cleaning', ['solvent clean', 'alkaline clean', 'soak clean']),
+ ('etch', ['etch', 'deoxidize', 'desmut', 'activation', 'acid dip']),
+ ('rinse', ['rinse']),
+ ('plate', ['anodize', 'plat', 'e-nickel', 'enp', 'chrome']),
+ ('bake', ['bake', 'seal', 'hot water seal']),
+ ('racking', ['racking', 'rack ', 'rack)']),
+ ('derack', ['unrack', 'de-rack', 'derack']),
+ ('mask', ['masking', 'apply mask']),
+ ('demask', ['de-mask', 'demask', 'unmask']),
+ ('dry', ['dry', 'drying']),
+ ('inspect', ['inspect']),
+ ('final_inspect', ['final inspect', 'final inspection']),
+ ('wbf_test', ['water break', 'wbf']),
+ ('ship', ['ship', 'shipping']),
+ ('packaging', ['packag', 'pack ']),
+ ('contract_review', ['contract review', 'qa-005']),
+ ('receiving', ['receiving', 'incoming']),
+ ('electroclean', ['electroclean', 'electro clean']),
+ ('strike', ['strike', 'wood']),
+ ('hardness_test', ['hardness']),
+ ('adhesion_test', ['adhesion']),
+ ('salt_spray', ['salt spray', 'corrosion test']),
+ ('replenishment', ['replenish', 'top-up']),
+ # Surface prep — fall back to cleaning since shops record similar fields
+ ('cleaning', ['blast']),
+]
+
+def infer_kind(name):
+ n = (name or '').lower()
+ for kind, kws in KIND_KEYWORDS:
+ if any(kw in n for kw in kws):
+ return kind
+ return None
+
+def collect_step_nodes(root, out):
+ if root.node_type in ('step', 'operation'):
+ out.append(root)
+ for c in root.child_ids:
+ collect_step_nodes(c, out)
+
+variant = Node.browse(1775)
+all_steps = []
+collect_step_nodes(variant, all_steps)
+find('INFO', 'Variant 1775 has %d step/operation nodes' % len(all_steps))
+
+# Patch default_kind
+patched = 0
+for s in all_steps:
+ if not s.default_kind:
+ guess = infer_kind(s.name)
+ if guess:
+ s.default_kind = guess
+ patched += 1
+ elif 'ready' in (s.name or '').lower():
+ s.default_kind = 'gating'
+ patched += 1
+find('INFO', 'Patched default_kind on %d nodes' % patched)
+
+# Seed prompts
+DEFAULTS = Template.DEFAULT_INPUTS_BY_KIND
+seeded = 0
+for s in all_steps:
+ if not s.default_kind or s.default_kind == 'gating':
+ continue
+ existing = set(s.input_ids.mapped('name'))
+ for spec in DEFAULTS.get(s.default_kind, []):
+ if spec['name'] in existing:
+ continue
+ NodeInput.create({
+ 'node_id': s.id,
+ 'name': spec['name'],
+ 'input_type': spec.get('input_type', 'text'),
+ 'kind': 'step_input',
+ 'collect': True,
+ 'sequence': spec.get('sequence', 10),
+ 'required': spec.get('required', False),
+ 'hint': spec.get('hint', ''),
+ 'selection_options': spec.get('selection_options', ''),
+ 'target_unit': spec.get('target_unit', False),
+ })
+ seeded += 1
+find('INFO', 'Seeded %d prompts onto variant subtree' % seeded)
+env.cr.commit()
+
+# ============================================================
+# Phase B — verify job 1234 sees the prompts
+# ============================================================
+print('\n========== PHASE B: job sees prompts ==========\n')
+
+job = env['fp.job'].browse(1234)
+job_steps = env['fp.job.step'].search([('job_id', '=', job.id)], order='sequence')
+prompt_total = 0
+for js in job_steps:
+ rn = js.recipe_node_id
+ cnt = len(rn.input_ids) if rn else 0
+ prompt_total += cnt
+find('INFO', 'Total prompts visible to job %d: %d across %d steps' % (
+ job.id, prompt_total, len(job_steps)
+))
+if prompt_total == 0:
+ find('FAIL', 'No prompts visible — variant cloning still broken')
+else:
+ find('PASS', 'Prompts now visible at runtime')
+
+# ============================================================
+# Phase C — record measurements covering every input type
+# ============================================================
+print('\n========== PHASE C: record measurements ==========\n')
+
+Move = env['fp.job.step.move']
+Value = env['fp.job.step.move.input.value']
+Att = env['ir.attachment']
+
+types_exercised = set()
+total_values = 0
+errors = []
+
+# Clear prior bt_* values so we re-record fresh each run
+prior_moves = Move.search([('job_id', '=', job.id), ('transfer_type', '=', 'step')])
+prior_values = Value.search([('move_id', 'in', prior_moves.ids)])
+prior_values.unlink()
+prior_moves.unlink()
+env.cr.commit()
+find('INFO', 'Cleared %d prior moves to re-record fresh' % len(prior_moves))
+
+for js in job_steps:
+ rn = js.recipe_node_id
+ if not rn:
+ continue
+ prompts = rn.input_ids.filtered(
+ lambda i: i.kind == 'step_input' and i.collect
+ )
+ if not prompts:
+ continue
+ move = Move.create({
+ 'job_id': job.id,
+ 'from_step_id': js.id,
+ 'to_step_id': js.id,
+ 'transfer_type': 'step',
+ 'qty_moved': job.qty or 1,
+ 'moved_by_user_id': env.user.id,
+ })
+ for p in prompts:
+ types_exercised.add(p.input_type)
+ vals = {'move_id': move.id, 'node_input_id': p.id}
+ try:
+ t = p.input_type
+ if t == 'photo':
+ att = Att.create({
+ 'name': 'bt_photo.png',
+ 'datas': b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
+ 'res_model': 'fp.job.step.move',
+ 'res_id': move.id,
+ })
+ vals['value_attachment_id'] = att.id
+ elif t == 'multi_point_thickness':
+ pts = [0.0010, 0.0011, 0.0012, 0.0011, 0.0013]
+ avg = sum(pts) / len(pts)
+ vals['value_text'] = json.dumps({'readings': pts, 'avg': avg})
+ vals['value_number'] = avg
+ elif t == 'bath_chemistry_panel':
+ vals['value_text'] = json.dumps({
+ 'ph': 4.8, 'concentration': 18.5,
+ 'temperature': 75.0, 'bath_id': 'TANK-A1',
+ })
+ elif t == 'ph':
+ vals['value_number'] = 4.8
+ elif t in ('boolean', 'pass_fail'):
+ vals['value_boolean'] = True
+ elif t == 'date':
+ vals['value_date'] = fields.Datetime.now()
+ elif t == 'selection':
+ opts = (p.selection_options or '').split(',')
+ vals['value_text'] = opts[0].strip() if opts and opts[0] else 'option_a'
+ elif t in ('number', 'temperature', 'thickness'):
+ vals['value_number'] = 42.5
+ elif t in ('time_seconds', 'time_hms'):
+ vals['value_number'] = 1800
+ elif t == 'signature':
+ vals['value_text'] = 'JD'
+ else:
+ vals['value_text'] = 'TEST-VAL'
+ Value.create(vals)
+ total_values += 1
+ except Exception as e:
+ errors.append('%s/%s: %s' % (p.name, p.input_type, str(e)[:120]))
+
+env.cr.commit()
+find('INFO', 'Recorded %d values, types exercised: %s' % (total_values, sorted(types_exercised)))
+if errors:
+ for e in errors[:8]:
+ find('FAIL', e)
+else:
+ find('PASS', 'All input types recorded without error')
+
+# ============================================================
+# Phase C-bonus: explicitly add a bath_chemistry_panel test
+# ============================================================
+# Find a prompt we can swap to bath_chemistry_panel for one move + value
+some_step = next((js for js in job_steps
+ if js.recipe_node_id and js.recipe_node_id.input_ids), None)
+if some_step:
+ rn = some_step.recipe_node_id
+ bcp_input = NodeInput.create({
+ 'node_id': rn.id,
+ 'name': 'BT Bath Panel Probe',
+ 'input_type': 'bath_chemistry_panel',
+ 'kind': 'step_input',
+ 'collect': True,
+ 'sequence': 999,
+ })
+ move_existing = Move.search([
+ ('from_step_id', '=', some_step.id),
+ ('transfer_type', '=', 'step'),
+ ], limit=1)
+ if move_existing:
+ Value.create({
+ 'move_id': move_existing.id,
+ 'node_input_id': bcp_input.id,
+ 'value_text': json.dumps({
+ 'ph': 4.8, 'concentration': 18.5,
+ 'temperature': 75.0, 'bath_id': 'TANK-A1',
+ }),
+ })
+ env.cr.commit()
+ find('INFO', 'Added bath_chemistry_panel test value to step %s' % some_step.name)
+ types_exercised.add('bath_chemistry_panel')
+
+# ============================================================
+# Phase D — render CoC chronological body via QWeb
+# ============================================================
+print('\n========== PHASE D: render CoC body ==========\n')
+
+try:
+ moves = Move.search([('job_id', '=', job.id)], order='move_datetime')
+ find('INFO', '%d moves on job %d' % (len(moves), job.id))
+ # Find or create a certificate for this job
+ Cert = env['fp.certificate']
+ cert = Cert.search([('x_fc_job_id', '=', job.id)], limit=1)
+ if not cert:
+ cert_vals = {'x_fc_job_id': job.id}
+ if 'partner_id' in Cert._fields:
+ cert_vals['partner_id'] = job.partner_id.id if hasattr(job, 'partner_id') and job.partner_id else 943
+ if 'name' in Cert._fields:
+ cert_vals['name'] = 'BT-CERT-%d' % job.id
+ cert = Cert.create(cert_vals)
+ find('INFO', 'Created certificate %d for job %d' % (cert.id, job.id))
+ rendered = env['ir.qweb']._render(
+ 'fusion_plating_reports.coc_body_chronological',
+ {
+ 'doc': cert,
+ 'docs': cert,
+ 'job': job,
+ 'moves': moves,
+ 'company': env.company,
+ 'res_company': env.company,
+ 'user': env.user,
+ },
+ )
+ if rendered:
+ body = rendered if isinstance(rendered, str) else rendered.decode('utf-8', errors='ignore')
+ find('PASS', 'CoC body rendered, len=%d' % len(body))
+ spot_checks = [
+ ('TEST-VAL', 'text value'),
+ ('42.5', 'number value'),
+ ('0.0011', 'multi-point avg'),
+ ('TANK-A1', 'bath panel bath_id'),
+ ('"ph": 4.8', 'bath panel pH'),
+ ('PASS', 'pass/fail rendered as PASS'),
+ ('readings', 'multi-point readings array'),
+ ('bt_photo.png', 'photo attachment filename'),
+ ]
+ for needle, desc in spot_checks:
+ if needle in body:
+ find('PASS', 'CoC %s ("%s" found)' % (desc, needle))
+ else:
+ find('WARN', 'CoC missing %s ("%s")' % (desc, needle))
+ # Save body to disk for visual inspection
+ with open('/tmp/bt_coc_body.html', 'w') as f:
+ f.write(body)
+ find('INFO', 'CoC body saved to /tmp/bt_coc_body.html on entech for inspection')
+ else:
+ find('FAIL', 'CoC body render returned empty')
+except Exception as e:
+ import traceback
+ find('FAIL', 'CoC render exception: %s' % str(e)[:200])
+ traceback.print_exc()
+
+# ============================================================
+# Summary
+# ============================================================
+print('\n========== SUMMARY ==========')
+passes = sum(1 for f in findings if f[0] == 'PASS')
+fails = sum(1 for f in findings if f[0] == 'FAIL')
+warns = sum(1 for f in findings if f[0] == 'WARN')
+print('PASS=%d FAIL=%d WARN=%d' % (passes, fails, warns))
+print('Job: %d' % job.id)
diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon.py
new file mode 100644
index 00000000..953398ba
--- /dev/null
+++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+"""End-to-end battle test — Phase 1: reconnaissance."""
+
+# Find ABC Manufacturing
+abc = env['res.partner'].search([('name', 'ilike', 'ABC Manufactor')], limit=1)
+if not abc:
+ abc = env['res.partner'].search([('name', 'ilike', 'ABC Manuf')], limit=1)
+print('ABC partner:', abc.id, abc.name if abc else 'NOT FOUND')
+
+# List recipes (process nodes that are recipe roots)
+recipes = env['fusion.plating.process.node'].search([
+ ('node_type', '=', 'recipe'),
+ ('parent_id', '=', False),
+ ('is_template', '=', False),
+])
+print('\nRecipes (non-template):')
+for r in recipes[:10]:
+ step_count = env['fusion.plating.process.node'].search_count([
+ ('parent_id', '=', r.id), ('node_type', '=', 'step'),
+ ])
+ print(' id=%d name=%s steps=%d code=%s' % (r.id, r.name, step_count, r.code or ''))
+
+# Anodize-specific recipes
+anodize = env['fusion.plating.process.node'].search([
+ ('node_type', '=', 'recipe'), ('name', 'ilike', 'anodize'),
+])
+print('\nAnodize-related:')
+for r in anodize[:5]:
+ print(' id=%d name=%s' % (r.id, r.name))
+
+# Find parts in the catalog for ABC (or any)
+parts = env['fp.part.catalog'].search([], limit=5)
+print('\nSample parts in catalog:')
+for p in parts:
+ print(' id=%d num=%s partner=%s' % (p.id, p.part_number, p.partner_id.name or ''))
+
+# Existing fp.job records
+jobs = env['fp.job'].search([], limit=5, order='id desc')
+print('\nRecent fp.job rows:')
+for j in jobs:
+ print(' id=%d origin=%s state=%s recipe=%s' % (
+ j.id, j.origin or '', j.state or '', j.recipe_id.name if j.recipe_id else 'no_recipe'
+ ))
+
+# Check what kinds of steps exist on a sample recipe
+if recipes:
+ big_recipe = max(recipes, key=lambda r: env['fusion.plating.process.node'].search_count([
+ ('parent_id', '=', r.id),
+ ]))
+ print('\nBiggest recipe: id=%d name=%s' % (big_recipe.id, big_recipe.name))
+ steps = env['fusion.plating.process.node'].search([
+ ('parent_id', '=', big_recipe.id),
+ ('node_type', '=', 'step'),
+ ], order='sequence')
+ for s in steps[:20]:
+ print(' step %d: %s (kind=%s)' % (s.sequence, s.name, s.default_kind or '-'))
diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon2.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon2.py
new file mode 100644
index 00000000..2b3923ab
--- /dev/null
+++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon2.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+"""Phase 2 recon — examine Anodize recipe tree + SO→job flow."""
+
+Node = env['fusion.plating.process.node']
+
+anodize = Node.browse(136)
+print('=== Anodize recipe (id=136) ===')
+print('name:', anodize.name)
+print('node_type:', anodize.node_type)
+print('is_template:', anodize.is_template)
+print('child_ids count:', len(anodize.child_ids))
+
+print('\nDirect children:')
+for c in anodize.child_ids.sorted('sequence'):
+ sub = c.child_ids
+ print(' [%d] %s (type=%s, kind=%s, %d sub)' % (
+ c.sequence, c.name, c.node_type, c.default_kind or '-', len(sub)
+ ))
+
+# Recurse one level deeper
+print('\nFull tree:')
+def walk(n, depth=0):
+ pad = ' ' * depth
+ print(pad + '%s [type=%s kind=%s collect_meas=%s inputs=%d]' % (
+ n.name, n.node_type, n.default_kind or '-',
+ getattr(n, 'collect_measurements', 'N/A'),
+ len(n.input_ids),
+ ))
+ for child in n.child_ids.sorted('sequence'):
+ walk(child, depth + 1)
+
+walk(anodize)
+
+# Sample part for ABC — make sure we have one
+abc = env['res.partner'].browse(943)
+parts = env['fp.part.catalog'].search([('partner_id', '=', abc.id)], limit=3)
+print('\n=== ABC parts ===')
+for p in parts:
+ print(' id=%d num=%s rev=%s' % (p.id, p.part_number, p.revision or ''))
+if not parts:
+ print(' (none — will need to create one)')
+
+# How does an SO line reference a recipe? Look at sale.order.line fields
+sol = env['sale.order.line']
+recipe_field_candidates = [
+ f for f in sol._fields
+ if 'recipe' in f.lower() or 'process' in f.lower() or 'variant' in f.lower()
+]
+print('\nSO line recipe-related fields:', recipe_field_candidates[:20])
diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon3.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon3.py
new file mode 100644
index 00000000..11d1fe77
--- /dev/null
+++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon3.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+"""Phase 3 recon — SO→job→step trigger pipeline."""
+
+# Find recent confirmed SO with x_fc_process_variant_id set
+sol = env['sale.order.line'].search([
+ ('x_fc_process_variant_id', '!=', False),
+], limit=5, order='id desc')
+print('=== Recent SO lines with process variant ===')
+for l in sol:
+ print(' SO=%s line=%d part=%s recipe=%s' % (
+ l.order_id.name, l.id, l.x_fc_part_catalog_id.part_number,
+ l.x_fc_process_variant_id.name,
+ ))
+
+# Pick one and trace its job
+if sol:
+ target = sol[0]
+ so = target.order_id
+ print('\nFor SO %s state=%s' % (so.name, so.state))
+ job = env['fp.job'].search([('origin', '=', so.name)], limit=1)
+ print('Linked fp.job:', job.id if job else 'NONE')
+ if job:
+ steps = env['fp.job.step'].search([('job_id', '=', job.id)], order='sequence')
+ print(' steps: %d' % len(steps))
+ for s in steps[:8]:
+ recipe_node = s.recipe_node_id
+ input_count = len(recipe_node.input_ids) if recipe_node else 0
+ print(' [%s] %s -- recipe_node=%d inputs=%d state=%s' % (
+ s.sequence, s.name, recipe_node.id if recipe_node else 0,
+ input_count, s.state,
+ ))
+
+# Check whether default_kind exists on every step in the Anodize recipe
+Node = env['fusion.plating.process.node']
+anodize = Node.browse(136)
+def collect_steps(n, out):
+ if n.node_type in ('step', 'operation'):
+ out.append(n)
+ for c in n.child_ids:
+ collect_steps(c, out)
+
+steps = []
+collect_steps(anodize, steps)
+print('\n=== Anodize step audit ===')
+print('Total step/operation nodes:', len(steps))
+no_kind = [s for s in steps if not s.default_kind]
+print('Without default_kind:', len(no_kind))
+for s in no_kind[:8]:
+ print(' - %s (type=%s)' % (s.name, s.node_type))
diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon4.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon4.py
new file mode 100644
index 00000000..e40d4a42
--- /dev/null
+++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon4.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+"""Phase 4 recon — diagnose recipe variant cloning."""
+
+Node = env['fusion.plating.process.node']
+n = Node.browse(1777)
+print('Node 1777:')
+print(' name:', n.name)
+print(' parent_id:', n.parent_id.id if n.parent_id else None)
+print(' default_kind:', n.default_kind)
+print(' inputs:', len(n.input_ids))
+print(' source_template_id:', n.source_template_id.id if n.source_template_id else None)
+
+# Walk up the parent chain to find the recipe root
+walker = n
+while walker and walker.parent_id:
+ walker = walker.parent_id
+ print(' ↑ parent:', walker.id, walker.name, 'type=', walker.node_type)
+
+print('\nRoot recipe of node 1777:', walker.id, walker.name)
+print('Anodize master is id=136; root for 1777 is id=%d (clone per-part variant)' % walker.id)
+
+# Check recipe_root_id field on the node
+print('\nrecipe_root_id on 1777:', n.recipe_root_id.id if n.recipe_root_id else None)
+
+# Find latest job and inspect its variant recipe root
+job = env['fp.job'].search([], limit=1, order='id desc')
+print('\nLatest job:', job.id, job.name, 'origin=', job.origin)
+print(' recipe_id (top-level):', job.recipe_id.id if job.recipe_id else None,
+ job.recipe_id.name if job.recipe_id else '')
+# How many steps?
+print(' steps count:', env['fp.job.step'].search_count([('job_id', '=', job.id)]))
+
+# Pull the Anodize variants
+anodize = Node.browse(136)
+variants = Node.search([
+ ('source_root_id', '=', anodize.id),
+]) if 'source_root_id' in Node._fields else env['fusion.plating.process.node']
+print('\nAnodize variants via source_root_id:', len(variants))
+# Try alternative
+variants2 = Node.search([
+ ('node_type', '=', 'recipe'),
+ ('name', 'ilike', 'Anodize'),
+])
+print('Recipes with name like Anodize:', len(variants2))
+for v in variants2[:5]:
+ print(' id=%d name=%s parts(any)=%s' % (v.id, v.name, v.part_catalog_id.id if 'part_catalog_id' in v._fields and v.part_catalog_id else '-'))
diff --git a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
index cd117221..a3c175a1 100644
--- a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
+++ b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
@@ -321,6 +321,83 @@ export class FpSimpleRecipeEditor extends Component {
this.state.editInstructions = "";
}
+ // -------------------- Sub 12d — measurements config --------------------
+
+ async onToggleStepCollect(stepId, collect) {
+ await rpc("/fp/simple_recipe/step/toggle_collect", {
+ node_id: stepId, collect,
+ });
+ await this.loadAll();
+ }
+
+ async onToggleInputCollect(inputId, collect) {
+ await rpc("/fp/simple_recipe/step/edit_input", {
+ input_id: inputId,
+ payload: { collect },
+ });
+ await this.loadAll();
+ }
+
+ async onEditInputField(inputId, field, value) {
+ const payload = {};
+ payload[field] = value;
+ await rpc("/fp/simple_recipe/step/edit_input", {
+ input_id: inputId,
+ payload,
+ });
+ await this.loadAll();
+ }
+
+ async onAddCustomInput(stepId) {
+ await rpc("/fp/simple_recipe/step/add_input", {
+ node_id: stepId,
+ payload: { name: _t("New Prompt"), input_type: "text" },
+ });
+ await this.loadAll();
+ }
+
+ /**
+ * Auto-save an input field on blur or change. Skips empty names so
+ * accidental Tab-out doesn't blank the prompt.
+ */
+ async onInputBlur(inputId, field, ev) {
+ const value = ev.target.value;
+ if (field === "name" && !value.trim()) return;
+ await this.onEditInputField(inputId, field, value);
+ }
+
+ async onRemoveCustomInput(inputId) {
+ const result = await rpc("/fp/simple_recipe/step/remove_input", {
+ input_id: inputId,
+ });
+ if (result.error === "library_sourced") {
+ this.notification.add(
+ _t("Library prompts can't be deleted — toggle Collect off instead."),
+ { type: "warning" }
+ );
+ return;
+ }
+ await this.loadAll();
+ }
+
+ async onResetToLibrary(stepId) {
+ const result = await rpc("/fp/simple_recipe/step/reset_to_library", {
+ node_id: stepId,
+ });
+ if (!result.ok) {
+ this.notification.add(
+ _t("This step has no linked library template."),
+ { type: "warning" }
+ );
+ return;
+ }
+ await this.loadAll();
+ this.notification.add(
+ _t("Reset to library defaults — custom prompts preserved"),
+ { type: "success" }
+ );
+ }
+
/**
* Render stored HTML as plain text for the textarea. Strips tags,
* collapses block elements to newlines. Good enough for the simple
diff --git a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss
index a559b561..2dacdeff 100644
--- a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss
+++ b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss
@@ -233,6 +233,27 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
color: $fp-se-muted;
}
+ .o_fp_measurements_config {
+ margin-top: .75rem;
+ padding-top: .75rem;
+ border-top: 1px solid #d8dadd;
+
+ .o_fp_inputs_table_wrap {
+ margin-top: .5rem;
+ }
+ .o_fp_inputs_table {
+ font-size: .85rem;
+ margin-bottom: .25rem;
+ th { font-weight: 500; }
+ td { vertical-align: middle; }
+ }
+ .o_fp_inputs_actions {
+ display: flex;
+ gap: .5rem;
+ margin-top: .25rem;
+ }
+ }
+
.o_fp_edit_actions {
display: flex;
gap: .5rem;
diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml
index 6bfb579e..4f8abd8a 100644
--- a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml
+++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml
@@ -71,6 +71,13 @@
t-if="step.tank_ids and step.tank_ids.length">
stations
+
+
+
+
+
+
+
+
+ Master switch. When off, the operator wizard skips this step entirely
+ (no input prompts shown at runtime).
+
+
+
+
+