diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 65d6b435..93e9cd03 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.18.7.0', + 'version': '19.0.18.7.3', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py index d497c2b5..bac67319 100644 --- a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py +++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py @@ -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} diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py index d36ff083..760c7e2b 100644 --- a/fusion_plating/fusion_plating/models/fp_process_node.py +++ b/fusion_plating/fusion_plating/models/fp_process_node.py @@ -292,6 +292,7 @@ class FpProcessNode(models.Model): 'fusion.plating.process.node.input', 'node_id', string='Operator Inputs', + copy=True, ) # ===== Sub 12a — Simple Editor + Step Library extensions ================= diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py new file mode 100644 index 00000000..643cc323 --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize.py @@ -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) diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_anodize_v2.py b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize_v2.py new file mode 100644 index 00000000..93f58166 --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_e2e_anodize_v2.py @@ -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) diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon.py new file mode 100644 index 00000000..953398ba --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon.py @@ -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 '-')) diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon2.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon2.py new file mode 100644 index 00000000..2b3923ab --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon2.py @@ -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]) diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon3.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon3.py new file mode 100644 index 00000000..11d1fe77 --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon3.py @@ -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)) diff --git a/fusion_plating/fusion_plating/scripts/bt_e2e_recon4.py b/fusion_plating/fusion_plating/scripts/bt_e2e_recon4.py new file mode 100644 index 00000000..e40d4a42 --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_e2e_recon4.py @@ -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 '-')) diff --git a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js index cd117221..a3c175a1 100644 --- a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js +++ b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js @@ -321,6 +321,83 @@ export class FpSimpleRecipeEditor extends Component { 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, * collapses block elements to newlines. Good enough for the simple diff --git a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss index a559b561..2dacdeff 100644 --- a/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss +++ b/fusion_plating/fusion_plating/static/src/scss/simple_recipe_editor.scss @@ -233,6 +233,27 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex}); 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 { display: flex; gap: .5rem; diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml index 6bfb579e..4f8abd8a 100644 --- a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml +++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml @@ -71,6 +71,13 @@ t-if="step.tank_ids and step.tank_ids.length"> stations + + + + + + + + +