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

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