Files
Odoo-Modules/fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py
gsinghpal ec0a07fbe9 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>
2026-04-29 22:53:59 -04:00

348 lines
13 KiB
Python

# -*- 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)