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>
326 lines
12 KiB
Python
326 lines
12 KiB
Python
# -*- 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)
|