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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user