fix(operator-wizard): surface office-authored instructions to operators

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-29 23:05:21 -04:00
parent ec0a07fbe9
commit 875548c547
5 changed files with 403 additions and 11 deletions

View File

@@ -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',
)

View File

@@ -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': '''<p><strong>Hard Anodize Type III per MIL-A-8625F</strong></p>
<p>Aluminum substrate. Black sulfuric dye. Hot nickel acetate seal.</p>
<p>Tolerances: 0.0015"0.0020" coating thickness, 60kV breakdown
voltage. Hardness ≥350HV300.</p>''',
})
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': '''<p><strong>Verify quantity and condition on arrival.</strong></p>
<ul><li>Count parts against the packing slip</li>
<li>Visually inspect for damage, corrosion, oil residue</li>
<li>Photograph any damage or non-conformance</li>
<li>Sign off below before parts move to staging</li></ul>''',
},
{
'name': '2. Contract Review (QA-005)',
'kind': 'contract_review',
'description': '''<p><strong>Verify the order against quality requirements.</strong></p>
<ul><li>Confirm spec matches PO (MIL-A-8625F Type III, Class 2 Black)</li>
<li>Cross-check thickness, hardness, salt-spray requirements</li>
<li>Open the QA-005 contract review form (separate dialog)</li>
<li>Approve only after all line items reconciled</li></ul>''',
},
{
'name': '3. Incoming Inspection',
'kind': 'inspect',
'description': '''<p><strong>First-piece inspection before processing.</strong></p>
<ul><li>Verify part number against drawing</li>
<li>Inspect surface for prior plating remnants, scratches, pitting</li>
<li>Photograph any defects</li>
<li>Record incoming dimensional spot-check</li></ul>''',
},
{
'name': '4. Racking',
'kind': 'racking',
'description': '''<p><strong>Mount parts on titanium rack.</strong></p>
<ul><li>Use Rack T-12 or similar (titanium, NOT aluminum)</li>
<li>Ensure firm electrical contact at rack hooks</li>
<li>Apply masking to threaded holes if required (see drawing)</li>
<li>Photograph the loaded rack before submerging</li></ul>''',
},
{
'name': '5. Alkaline Clean (Tank A-1)',
'kind': 'cleaning',
'description': '''<p><strong>Soak clean — Aluminum Etch Cleaner.</strong></p>
<ul><li>Tank: A-1, Bath: ALKCLEAN-1</li>
<li>Time: 46 minutes</li>
<li>Temperature: 140160°F</li>
<li>Confirm titration done within last 24 hours</li></ul>''',
},
{
'name': '6. Rinse — Cascade DI (Tank A-2)',
'kind': 'rinse',
'description': '''<p><strong>Triple cascade DI rinse.</strong></p>
<ul><li>Tank A-2 (DI rinse, conductivity &lt; 50 µS/cm)</li>
<li>Time: 30 seconds, agitate</li>
<li>Verify conductivity reading on bath log</li></ul>''',
},
{
'name': '7. Etch (Tank A-3)',
'kind': 'etch',
'description': '''<p><strong>Caustic etch — sodium hydroxide bath.</strong></p>
<ul><li>Tank: A-3, Bath: ETCH-1</li>
<li>Time: 3090 seconds (per drawing — heavy etch removes 0.0005"/side)</li>
<li>Temperature: 130150°F</li>
<li>Concentration: 46 oz/gal NaOH</li>
<li>HE-risk parts (high-strength) require post-bake — flag accordingly</li></ul>''',
},
{
'name': '8. Rinse — Cascade DI (Tank A-4)',
'kind': 'rinse',
'description': '<p>Triple cascade DI rinse — Tank A-4. 30 sec agitate.</p>',
},
{
'name': '9. Desmut / Deoxidize (Tank A-5)',
'kind': 'etch',
'description': '''<p><strong>Acid desmut to remove black smut from etch.</strong></p>
<ul><li>Tank: A-5, Bath: DEOX-1 (HNO3-based)</li>
<li>Time: 3060 seconds</li>
<li>Temperature: ambient</li>
<li>Surface should be water-break-free after this step</li></ul>''',
},
{
'name': '10. Rinse — Cascade DI (Tank A-6)',
'kind': 'rinse',
'description': '<p>Final pre-anodize rinse — Tank A-6. Conductivity must be &lt; 50 µS/cm.</p>',
},
{
'name': '11. Hard Anodize Type III (Tank A-9)',
'kind': 'plate',
'description': '''<p><strong>HARD ANODIZE — the primary process step.</strong></p>
<ul><li>Tank: A-9, Bath: HARDANO-1 (15% sulfuric acid)</li>
<li>Temperature: 2832°F (chilled bath — confirm chiller is running)</li>
<li>Current density: 2436 ASF</li>
<li>Voltage ramp: 080V over first 5 minutes</li>
<li>Time at voltage: 60 minutes (gives ~0.002" coating)</li>
<li>Record amperage every 15 minutes</li>
<li>Check thickness midway with Fischerscope on witness coupon</li>
<li>If color reading off, halt and call supervisor</li></ul>''',
},
{
'name': '12. Rinse — Cold (Tank A-12)',
'kind': 'rinse',
'description': '<p>Cold cascade rinse to remove sulfuric residue. Tank A-12.</p>',
},
{
'name': '13. Black Dye Bath (Tank A-14)',
'kind': 'plate',
'description': '''<p><strong>Sulfo Black BL dye absorption.</strong></p>
<ul><li>Tank: A-14, Bath: DYE-BL-1</li>
<li>Temperature: 130150°F</li>
<li>Time: 1218 minutes</li>
<li>Maintain pH 5.06.0</li>
<li>Visually verify uniform black with no streaks before sealing</li></ul>''',
},
{
'name': '14. Rinse — Warm (Tank A-15)',
'kind': 'rinse',
'description': '<p>Warm rinse before sealing. Tank A-15. ~110°F.</p>',
},
{
'name': '15. Hot Nickel Acetate Seal (Tank A-16)',
'kind': 'bake',
'description': '''<p><strong>Nickel acetate seal — locks in dye, improves corrosion resistance.</strong></p>
<ul><li>Tank: A-16, Bath: SEAL-NA-1</li>
<li>Temperature: 195205°F</li>
<li>Time: 1822 minutes</li>
<li>pH: 5.56.0</li>
<li>Attach AMS-2759 chart-recorder file as photo before unloading</li>
<li>Quality of seal verified post-process by dye absorption test</li></ul>''',
},
{
'name': '16. Hot DI Rinse (Tank A-17)',
'kind': 'rinse',
'description': '<p>Final hot DI rinse. Tank A-17. 180°F+. Drives off residual seal solution.</p>',
},
{
'name': '17. Drying (Hot Air Knife)',
'kind': 'dry',
'description': '''<p><strong>Hot-air knife dry — leave parts on rack.</strong></p>
<ul><li>Hot air knife @ 180°F</li>
<li>Time: 5 minutes minimum</li>
<li>Verify parts fully dry before unracking — water spotting is a defect</li></ul>''',
},
{
'name': '18. De-Racking',
'kind': 'derack',
'description': '''<p><strong>Remove parts from rack carefully.</strong></p>
<ul><li>Wear lint-free gloves</li>
<li>Inspect each part for rack-mark touch-up needs</li>
<li>Place on padded staging cart</li></ul>''',
},
{
'name': '19. Final Visual Inspection',
'kind': 'final_inspect',
'description': '''<p><strong>Visual + dimensional + thickness QC.</strong></p>
<ul><li>Visual: uniform black, no streaks, scratches, or burn</li>
<li>Dimensional: spot-check 3 critical dims per part</li>
<li>Thickness: 5-point Fischerscope reading per drawing locations</li>
<li>Photograph any defects, route to NCR if rework needed</li>
<li>Inspector signs off below</li></ul>''',
},
{
'name': '20. Microhardness Test (Witness Coupon)',
'kind': 'hardness_test',
'description': '''<p><strong>HV300 hardness measurement on witness coupon.</strong></p>
<ul><li>Equipment: LECO LM247AT microhardness tester (verify cal date current)</li>
<li>Load: 300 gf, dwell 10s</li>
<li>Take 3 indents, log each + average</li>
<li>Spec: ≥350 HV300 (MIL-A-8625F Type III)</li></ul>''',
},
{
'name': '21. Salt Spray (Witness, ASTM B117)',
'kind': 'salt_spray',
'description': '''<p><strong>336-hour salt spray on witness coupon.</strong></p>
<ul><li>Submit 3 coupons to lab</li>
<li>Test duration: 336 hours minimum</li>
<li>Acceptance: zero red rust, &lt; 5% white corrosion</li>
<li>Attach lab certificate when received</li></ul>''',
},
{
'name': '22. Packaging',
'kind': 'packaging',
'description': '''<p><strong>Wrap and stage for shipment.</strong></p>
<ul><li>Wrap each part in VCI paper</li>
<li>Place in foam-lined box, max 4 parts per box</li>
<li>Include CoC + dimensional report inside the box</li>
<li>Seal with company tape</li></ul>''',
},
{
'name': '23. Shipping',
'kind': 'ship',
'description': '''<p><strong>Outbound — confirm carrier and BoL.</strong></p>
<ul><li>Carrier per SO (UPS / FedEx / customer pickup)</li>
<li>Print BoL, attach to package</li>
<li>Photograph sealed shipment for proof-of-shipment</li>
<li>Update tracking # in this SO</li></ul>''',
},
]
# ----- 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)

View File

@@ -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.',

View File

@@ -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)

View File

@@ -11,6 +11,17 @@
<field name="step_id" readonly="1"/>
<field name="job_id" readonly="1"/>
</group>
<field name="has_instructions" invisible="1"/>
<div class="alert alert-info"
role="alert"
invisible="not has_instructions"
style="margin-bottom: 12px;">
<h4 style="margin-top: 0;">
<i class="fa fa-info-circle"/>
Instructions for this step
</h4>
<field name="instructions" nolabel="1" readonly="1"/>
</div>
<separator string="Measurements"/>
<p class="text-muted" invisible="line_ids">
Click <strong>Add a line</strong> to record one or