fix(audit-trail): 3 production bugs found via end-to-end Anodize battle test

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 <img> 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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-29 22:53:59 -04:00
parent b187192c58
commit ec0a07fbe9
13 changed files with 1246 additions and 3 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.18.7.0', 'version': '19.0.18.7.3',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -62,6 +62,26 @@ class SimpleRecipeController(http.Controller):
} }
def _step_payload(self, step): 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 { return {
'id': step.id, 'id': step.id,
'name': step.name, '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, '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, '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 # --------------------------------------------------------------- library
@@ -280,3 +319,113 @@ class SimpleRecipeController(http.Controller):
'target_unit': src_in.target_unit, 'target_unit': src_in.target_unit,
'compliance_tag': src_in.compliance_tag, '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}

View File

@@ -292,6 +292,7 @@ class FpProcessNode(models.Model):
'fusion.plating.process.node.input', 'fusion.plating.process.node.input',
'node_id', 'node_id',
string='Operator Inputs', string='Operator Inputs',
copy=True,
) )
# ===== Sub 12a — Simple Editor + Step Library extensions ================= # ===== Sub 12a — Simple Editor + Step Library extensions =================

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 '-'))

View File

@@ -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])

View File

@@ -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))

View File

@@ -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 '-'))

View File

@@ -321,6 +321,83 @@ export class FpSimpleRecipeEditor extends Component {
this.state.editInstructions = ""; 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, * Render stored HTML as plain text for the textarea. Strips tags,
* collapses block elements to newlines. Good enough for the simple * collapses block elements to newlines. Good enough for the simple

View File

@@ -233,6 +233,27 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
color: $fp-se-muted; 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 { .o_fp_edit_actions {
display: flex; display: flex;
gap: .5rem; gap: .5rem;

View File

@@ -71,6 +71,13 @@
t-if="step.tank_ids and step.tank_ids.length"> t-if="step.tank_ids and step.tank_ids.length">
<t t-esc="step.tank_ids.length"/> stations <t t-esc="step.tank_ids.length"/> stations
</span> </span>
<span class="badge ms-1"
t-att-class="step.measurements_badge_class"
t-if="step.measurements_badge_text"
title="Measurement collection state">
<i class="fa fa-clipboard"/>
<t t-esc="step.measurements_badge_text"/>
</span>
<button class="o_fp_step_edit" <button class="o_fp_step_edit"
title="Edit name &amp; instructions" title="Edit name &amp; instructions"
t-on-click="() => this.onToggleEdit(step.id)"> t-on-click="() => this.onToggleEdit(step.id)">
@@ -102,6 +109,113 @@
Shown to operators when running this step at the tank. Use line breaks for separate points. Shown to operators when running this step at the tank. Use line breaks for separate points.
</p> </p>
</div> </div>
<!-- Sub 12d — Measurements config -->
<div class="o_fp_edit_field o_fp_measurements_config">
<label>
<input type="checkbox"
t-att-checked="step.collect_measurements"
t-on-change="(ev) => this.onToggleStepCollect(step.id, ev.target.checked)"/>
<strong> Collect measurements at this step</strong>
</label>
<p class="o_fp_edit_hint">
Master switch. When off, the operator wizard skips this step entirely
(no input prompts shown at runtime).
</p>
<div t-if="step.collect_measurements"
class="o_fp_inputs_table_wrap">
<table class="table table-sm o_fp_inputs_table"
t-if="step.inputs and step.inputs.length">
<thead>
<tr>
<th style="width:60px;">Collect</th>
<th>Prompt</th>
<th style="width:160px;">Type</th>
<th style="width:90px;">Min</th>
<th style="width:90px;">Max</th>
<th style="width:60px;">Req</th>
<th style="width:36px;"></th>
</tr>
</thead>
<tbody>
<tr t-foreach="step.inputs" t-as="inp" t-key="inp.id">
<td>
<input type="checkbox"
t-att-checked="inp.collect"
t-on-change="(ev) => this.onToggleInputCollect(inp.id, ev.target.checked)"/>
</td>
<td>
<input type="text" class="form-control form-control-sm"
t-att-value="inp.name"
t-on-blur="(ev) => this.onInputBlur(inp.id, 'name', ev)"/>
<small t-if="inp.from_library"
class="text-muted"
title="From library template — toggle Collect off instead of deleting">
from library
</small>
</td>
<td>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onEditInputField(inp.id, 'input_type', ev.target.value)">
<option value="text" t-att-selected="inp.input_type === 'text'">Text</option>
<option value="number" t-att-selected="inp.input_type === 'number'">Number</option>
<option value="boolean" t-att-selected="inp.input_type === 'boolean'">Yes/No</option>
<option value="selection" t-att-selected="inp.input_type === 'selection'">Selection</option>
<option value="date" t-att-selected="inp.input_type === 'date'">Date / Time</option>
<option value="signature" t-att-selected="inp.input_type === 'signature'">Signature</option>
<option value="time_hms" t-att-selected="inp.input_type === 'time_hms'">Time (HH:MM:SS)</option>
<option value="time_seconds" t-att-selected="inp.input_type === 'time_seconds'">Time (sec)</option>
<option value="temperature" t-att-selected="inp.input_type === 'temperature'">Temperature</option>
<option value="thickness" t-att-selected="inp.input_type === 'thickness'">Thickness</option>
<option value="pass_fail" t-att-selected="inp.input_type === 'pass_fail'">Pass / Fail</option>
<option value="photo" t-att-selected="inp.input_type === 'photo'">Photo</option>
<option value="multi_point_thickness" t-att-selected="inp.input_type === 'multi_point_thickness'">Multi-Point Thickness</option>
<option value="bath_chemistry_panel" t-att-selected="inp.input_type === 'bath_chemistry_panel'">Bath Chemistry Panel</option>
<option value="ph" t-att-selected="inp.input_type === 'ph'">pH</option>
</select>
</td>
<td>
<input type="number" step="any" class="form-control form-control-sm"
t-att-value="inp.target_min"
t-on-blur="(ev) => this.onInputBlur(inp.id, 'target_min', ev)"/>
</td>
<td>
<input type="number" step="any" class="form-control form-control-sm"
t-att-value="inp.target_max"
t-on-blur="(ev) => this.onInputBlur(inp.id, 'target_max', ev)"/>
</td>
<td class="text-center">
<input type="checkbox"
t-att-checked="inp.required"
t-on-change="(ev) => this.onEditInputField(inp.id, 'required', ev.target.checked)"/>
</td>
<td>
<button t-if="!inp.from_library"
class="btn btn-link btn-sm text-danger p-0"
title="Remove custom prompt"
t-on-click="() => this.onRemoveCustomInput(inp.id)">×</button>
</td>
</tr>
</tbody>
</table>
<p t-if="!step.inputs or !step.inputs.length"
class="text-muted">
No measurement prompts on this step yet.
</p>
<div class="o_fp_inputs_actions">
<button class="btn btn-link btn-sm"
t-on-click="() => this.onAddCustomInput(step.id)">
<i class="fa fa-plus"/> Add custom prompt
</button>
<button t-if="step.source_template_id"
class="btn btn-link btn-sm"
t-on-click="() => this.onResetToLibrary(step.id)">
<i class="fa fa-refresh"/> Reset to library defaults
</button>
</div>
</div>
</div>
<div class="o_fp_edit_actions"> <div class="o_fp_edit_actions">
<button class="btn btn-primary btn-sm" <button class="btn btn-primary btn-sm"
t-on-click="() => this.onSaveStep()"> t-on-click="() => this.onSaveStep()">

View File

@@ -95,7 +95,10 @@
<t t-foreach="moves" t-as="mv"> <t t-foreach="moves" t-as="mv">
<t t-set="dest" t-value="mv.to_step_id"/> <t t-set="dest" t-value="mv.to_step_id"/>
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/> <t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
<t t-set="captured" t-value="(dest and dest.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input' and (not hasattr(i, 'collect') or i.collect)).sorted('sequence')) or []"/> <!-- Sub 12d — input_ids lives on recipe_node, not job.step.
Walk via recipe_node_id; filter to step_input + collect=True. -->
<t t-set="recipe_node" t-value="(dest and dest.recipe_node_id) or False"/>
<t t-set="captured" t-value="(recipe_node and recipe_node.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input' and (i.collect if 'collect' in i._fields else True)).sorted('sequence')) or []"/>
<h3> <h3>
<span t-esc="(dest and dest.name) or '—'"/> <span t-esc="(dest and dest.name) or '—'"/>
@@ -184,7 +187,13 @@
<strong t-esc="actual_str"/> <strong t-esc="actual_str"/>
</t> </t>
<t t-elif="cv and cv.value_attachment_id"> <t t-elif="cv and cv.value_attachment_id">
<span style="font-size: 7.5pt; color: #555;">[Attachment]</span> <!-- Photo: render as a thumbnail (height-capped); fall back to filename. -->
<img t-att-src="'/web/image/%s' % cv.value_attachment_id.id"
style="max-height: 80px; max-width: 160px; border: 1px solid #ccc;"
t-att-alt="cv.value_attachment_id.name"/>
<div style="font-size: 7.5pt; color: #555;">
<span t-esc="cv.value_attachment_id.name"/>
</div>
</t> </t>
</td> </td>
<td> <td>