From 875548c5472c8e8d2a019d505a34be70288669d1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 29 Apr 2026 23:05:21 -0400 Subject: [PATCH] fix(operator-wizard): surface office-authored instructions to operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical UX gap discovered in production-environment battle test: when operator hits "Mark Done" and the input wizard fires, they only saw the measurement prompts list. The rich-text instructions written by the office (recipe_node.description) never reached the operator at the exact moment they need them. Fixed: wizard model gains instructions (Html, computed from step.recipe_node_id.description) + has_instructions flag. Form view renders the instructions in a prominent blue alert at the top of the wizard, above the Measurements list. Hidden when blank so operators on instruction-less steps don't see noise. Also: extend default_kind Selection on fusion.plating.process.node to match fp.step.template — both models now have the same 24 kinds. Without this, recipe authors could pick a kind in the library template form that the recipe-node Selection rejected with a ValueError. Battle test artifact: - Recipe "Hard Anodize Type III + Dye + Seal" (id=1863) — 23 steps, 105 measurement prompts, rich-text operator instructions per step - SO S00278 for ABC Manufactoring confirmed → fp.job 1236 / WH/JOB/00337 with all 23 steps materialized, 105 prompts visible to operators - Wizard test: step "11. Hard Anodize Type III" → 516 chars of instructions render + 7 input prompts in the form Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating/models/fp_process_node.py | 28 +- .../scripts/bt_create_hard_anodize.py | 353 ++++++++++++++++++ .../fusion_plating_jobs/__manifest__.py | 2 +- .../wizards/fp_job_step_input_wizard.py | 20 + .../fp_job_step_input_wizard_views.xml | 11 + 5 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 fusion_plating/fusion_plating/scripts/bt_create_hard_anodize.py diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py index 760c7e2b..33e732fc 100644 --- a/fusion_plating/fusion_plating/models/fp_process_node.py +++ b/fusion_plating/fusion_plating/models/fp_process_node.py @@ -348,22 +348,30 @@ class FpProcessNode(models.Model): ) default_kind = fields.Selection( [ - ('cleaning', 'Cleaning'), - ('etch', 'Etch'), - ('rinse', 'Rinse'), - ('plate', 'Plating'), - ('bake', 'Bake'), - ('inspect', 'Inspection'), + ('receiving', 'Receiving / Incoming Inspection'), + ('contract_review', 'Contract Review (QA-005)'), ('racking', 'Racking'), - ('derack', 'De-Racking'), ('mask', 'Masking'), - ('demask', 'De-Masking'), - ('dry', 'Drying'), + ('cleaning', 'Cleaning'), + ('electroclean', 'Electroclean'), + ('etch', 'Etch / Activation'), + ('rinse', 'Rinse'), + ('strike', 'Strike (Wood\'s Nickel / Activation)'), + ('plate', 'Plating'), + ('replenishment', 'Tank Replenishment'), ('wbf_test', 'Water Break Free Test'), + ('dry', 'Drying'), + ('bake', 'Bake (HE Relief / Stress Relief)'), + ('demask', 'De-Masking'), + ('derack', 'De-Racking'), + ('inspect', 'Inspection'), + ('hardness_test', 'Hardness Test (HV / HK / HRC)'), + ('adhesion_test', 'Adhesion Test'), + ('salt_spray', 'Salt Spray / Corrosion Test'), ('final_inspect', 'Final Inspection'), + ('packaging', 'Packaging / Pre-Ship'), ('ship', 'Shipping'), ('gating', 'Gating'), - ('contract_review', 'Contract Review (QA-005)'), ], string='Step Kind', ) diff --git a/fusion_plating/fusion_plating/scripts/bt_create_hard_anodize.py b/fusion_plating/fusion_plating/scripts/bt_create_hard_anodize.py new file mode 100644 index 00000000..5dc6b0d1 --- /dev/null +++ b/fusion_plating/fusion_plating/scripts/bt_create_hard_anodize.py @@ -0,0 +1,353 @@ +# -*- coding: utf-8 -*- +"""Create a brand-new Hard Anodize Type III + Dye + Seal recipe with +rich operator instructions + measurement prompts on every step, +then create an SO for ABC Manufacturing and confirm it so the +operator-facing job is ready to run. + +After running, the user navigates to: + - Recipe form (Process Recipes menu) — verify instructions present + - Simple Recipe Editor — verify per-step Instructions + Measurements + - Sale Orders — verify the new SO with line referencing the recipe + - Plating Jobs — verify job created with all steps + - Click Mark Done on any step → verify operator wizard shows + instructions + measurement prompts +""" + +import json +from odoo import fields + +Node = env['fusion.plating.process.node'] +NodeInput = env['fusion.plating.process.node.input'] +Template = env['fp.step.template'] + +print('\n========== Build Hard Anodize Type III Recipe ==========\n') + +# Clean up any prior run of this script (prior recipes + their variants + jobs + SOs) +prior_recipes = Node.search([ + '|', ('name', '=', 'Hard Anodize Type III + Dye + Seal'), + ('name', 'ilike', 'Hard Anodize Type III + Dye + Seal — '), +]) +if prior_recipes: + print('Cleaning up %d prior recipe(s)...' % len(prior_recipes)) + # Cancel + delete jobs that reference these recipes + prior_jobs = env['fp.job'].search([('recipe_id', 'in', prior_recipes.ids)]) + for j in prior_jobs: + try: + j.write({'state': 'cancel'}) + except Exception: + pass + prior_sos = env['sale.order'].search([ + ('order_line.x_fc_process_variant_id', 'in', prior_recipes.ids), + ('state', 'in', ('draft', 'sent')), + ]) + prior_sos.unlink() + prior_jobs.unlink() + prior_recipes.unlink() + env.cr.commit() + +# ----- Recipe root ----- +recipe = Node.create({ + 'name': 'Hard Anodize Type III + Dye + Seal', + 'code': 'HARD_ANO_T3', + 'node_type': 'recipe', + 'is_template': True, + 'description': '''

Hard Anodize Type III per MIL-A-8625F

+

Aluminum substrate. Black sulfuric dye. Hot nickel acetate seal.

+

Tolerances: 0.0015"–0.0020" coating thickness, 60kV breakdown +voltage. Hardness ≥350HV300.

''', +}) +print('Created recipe:', recipe.id, recipe.name) + +# ----- Step definitions ----- +# Each spec: name, kind, description (operator instructions), extra_prompts (additional manually-authored) +STEPS = [ + { + 'name': '1. Receiving', + 'kind': 'receiving', + 'description': '''

Verify quantity and condition on arrival.

+''', + }, + { + 'name': '2. Contract Review (QA-005)', + 'kind': 'contract_review', + 'description': '''

Verify the order against quality requirements.

+''', + }, + { + 'name': '3. Incoming Inspection', + 'kind': 'inspect', + 'description': '''

First-piece inspection before processing.

+''', + }, + { + 'name': '4. Racking', + 'kind': 'racking', + 'description': '''

Mount parts on titanium rack.

+''', + }, + { + 'name': '5. Alkaline Clean (Tank A-1)', + 'kind': 'cleaning', + 'description': '''

Soak clean — Aluminum Etch Cleaner.

+''', + }, + { + 'name': '6. Rinse — Cascade DI (Tank A-2)', + 'kind': 'rinse', + 'description': '''

Triple cascade DI rinse.

+''', + }, + { + 'name': '7. Etch (Tank A-3)', + 'kind': 'etch', + 'description': '''

Caustic etch — sodium hydroxide bath.

+''', + }, + { + 'name': '8. Rinse — Cascade DI (Tank A-4)', + 'kind': 'rinse', + 'description': '

Triple cascade DI rinse — Tank A-4. 30 sec agitate.

', + }, + { + 'name': '9. Desmut / Deoxidize (Tank A-5)', + 'kind': 'etch', + 'description': '''

Acid desmut to remove black smut from etch.

+''', + }, + { + 'name': '10. Rinse — Cascade DI (Tank A-6)', + 'kind': 'rinse', + 'description': '

Final pre-anodize rinse — Tank A-6. Conductivity must be < 50 µS/cm.

', + }, + { + 'name': '11. Hard Anodize Type III (Tank A-9)', + 'kind': 'plate', + 'description': '''

HARD ANODIZE — the primary process step.

+''', + }, + { + 'name': '12. Rinse — Cold (Tank A-12)', + 'kind': 'rinse', + 'description': '

Cold cascade rinse to remove sulfuric residue. Tank A-12.

', + }, + { + 'name': '13. Black Dye Bath (Tank A-14)', + 'kind': 'plate', + 'description': '''

Sulfo Black BL dye absorption.

+''', + }, + { + 'name': '14. Rinse — Warm (Tank A-15)', + 'kind': 'rinse', + 'description': '

Warm rinse before sealing. Tank A-15. ~110°F.

', + }, + { + 'name': '15. Hot Nickel Acetate Seal (Tank A-16)', + 'kind': 'bake', + 'description': '''

Nickel acetate seal — locks in dye, improves corrosion resistance.

+''', + }, + { + 'name': '16. Hot DI Rinse (Tank A-17)', + 'kind': 'rinse', + 'description': '

Final hot DI rinse. Tank A-17. 180°F+. Drives off residual seal solution.

', + }, + { + 'name': '17. Drying (Hot Air Knife)', + 'kind': 'dry', + 'description': '''

Hot-air knife dry — leave parts on rack.

+''', + }, + { + 'name': '18. De-Racking', + 'kind': 'derack', + 'description': '''

Remove parts from rack carefully.

+''', + }, + { + 'name': '19. Final Visual Inspection', + 'kind': 'final_inspect', + 'description': '''

Visual + dimensional + thickness QC.

+''', + }, + { + 'name': '20. Microhardness Test (Witness Coupon)', + 'kind': 'hardness_test', + 'description': '''

HV300 hardness measurement on witness coupon.

+''', + }, + { + 'name': '21. Salt Spray (Witness, ASTM B117)', + 'kind': 'salt_spray', + 'description': '''

336-hour salt spray on witness coupon.

+''', + }, + { + 'name': '22. Packaging', + 'kind': 'packaging', + 'description': '''

Wrap and stage for shipment.

+''', + }, + { + 'name': '23. Shipping', + 'kind': 'ship', + 'description': '''

Outbound — confirm carrier and BoL.

+''', + }, +] + +# ----- Build steps ----- +DEFAULTS = Template.DEFAULT_INPUTS_BY_KIND +total_prompts = 0 +for idx, spec in enumerate(STEPS): + step = Node.create({ + 'name': spec['name'], + # node_type='operation' is required for fp.job to materialize a + # work-order step from this node. 'step' nodes are sub-elements + # under an operation (e.g. child rinses), not job-step builders. + 'node_type': 'operation', + 'parent_id': recipe.id, + 'sequence': (idx + 1) * 10, + 'default_kind': spec['kind'], + 'description': spec['description'], + 'collect_measurements': True, + }) + # Seed prompts based on kind + for input_spec in DEFAULTS.get(spec['kind'], []): + NodeInput.create({ + 'node_id': step.id, + 'name': input_spec['name'], + 'input_type': input_spec.get('input_type', 'text'), + 'kind': 'step_input', + 'collect': True, + 'sequence': input_spec.get('sequence', 10), + 'required': input_spec.get('required', False), + 'hint': input_spec.get('hint', ''), + 'selection_options': input_spec.get('selection_options', ''), + 'target_unit': input_spec.get('target_unit', False), + }) + total_prompts += 1 + +env.cr.commit() +print('Built %d steps with %d total prompts' % (len(STEPS), total_prompts)) + +# ----- Create SO for ABC Manufacturing ----- +print('\n========== Create SO for ABC ==========\n') + +abc = env['res.partner'].browse(943) +part = env['fp.part.catalog'].browse(148) # ABC part 4321 +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-HARDANO-001', + 'client_order_ref': 'BT-PO-HARDANO-001', +}) +sol = env['sale.order.line'].create({ + 'order_id': so.id, + 'product_id': prod.id, + 'product_uom_qty': 10, + 'price_unit': 350.0, + 'name': 'Hard Anodize Type III + Black Dye + Seal', + 'x_fc_part_catalog_id': part.id, + 'x_fc_process_variant_id': recipe.id, +}) +print('Created SO:', so.name, 'line', sol.id) + +# Confirm — triggers fp.job creation +so.action_confirm() +print('Confirmed SO. State =', so.state) +env.cr.commit() + +# Find resulting job +job = env['fp.job'].search([('origin', '=', so.name)], limit=1) +print('\n========== Job created ==========\n') +print('Job:', job.id, job.name, '| recipe variant:', job.recipe_id.name if job.recipe_id else None) +job_steps = env['fp.job.step'].search([('job_id', '=', job.id)], order='sequence') +print('Steps on job:', len(job_steps)) + +print('\n========== Verification ==========\n') +prompts_visible = 0 +instructions_visible = 0 +for js in job_steps: + rn = js.recipe_node_id + if rn: + ins = bool(rn.description) + prompts = len(rn.input_ids.filtered(lambda i: i.collect)) + instructions_visible += int(ins) + prompts_visible += prompts + print(' [%2d] %s — instructions=%s prompts=%d kind=%s' % ( + js.sequence, js.name, '✓' if ins else '✗', prompts, rn.default_kind or '-' + )) + +print('\nSummary:') +print(' Steps with instructions:', instructions_visible, '/', len(job_steps)) +print(' Total prompts visible to operators:', prompts_visible) +print('\n========== URLs to verify ==========') +print('Recipe: /odoo/action-fusion_plating.action_fp_process_node/%d' % recipe.id) +print('Sale Order:/odoo/sales/%d' % so.id) +print('Job: /odoo/action-fusion_plating_jobs.action_fp_job/%d' % job.id) +print(' → click any step → "Mark Done" → operator wizard should show:') +print(' - Step description (instructions for operator)') +print(' - All %d collect=True prompts as input fields' % prompts_visible) diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 9d2f1389..7cce258d 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.8.12.0', + 'version': '19.0.8.13.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py b/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py index f8e0bff7..c99ae184 100644 --- a/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py +++ b/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py @@ -59,11 +59,31 @@ class FpJobStepInputWizard(models.TransientModel): job_id = fields.Many2one( related='step_id.job_id', string='Job', store=False, readonly=True, ) + # Sub 12d — surface the office-authored instructions to the operator + # at the exact moment they're recording values. Sourced from the + # recipe node's description (rich-text); empty when the recipe + # author left it blank. + instructions = fields.Html( + string='Operator Instructions', + compute='_compute_instructions', + readonly=True, + ) + has_instructions = fields.Boolean( + compute='_compute_instructions', + ) line_ids = fields.One2many( 'fp.job.step.input.wizard.line', 'wizard_id', string='Inputs', ) + @api.depends('step_id', 'step_id.recipe_node_id', 'step_id.recipe_node_id.description') + def _compute_instructions(self): + for rec in self: + node = rec.step_id.recipe_node_id if rec.step_id else False + html = (node and node.description) or '' + rec.instructions = html + rec.has_instructions = bool(html and html.strip()) + @api.model def default_get(self, fields_list): defaults = super().default_get(fields_list) diff --git a/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml b/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml index 54bca05e..ef476b4f 100644 --- a/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml +++ b/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml @@ -11,6 +11,17 @@ + +

Click Add a line to record one or