# Step Library Expansion + Per-Recipe Configurability + Audit Coverage — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Cover the full plating-shop workflow with new Step Kinds + audit-grade default measurements, expose per-recipe configurability for every prompt + master toggle + custom prompts + reset-to-library, surface office-to-operator instructions in the simple recipe editor with library/recipe override, wire all of it through to runtime + CoC report + tablet, and battle-test end-to-end on entech. **Architecture:** Library `fp.step.template` is the smart default; recipe `fusion.plating.process.node` is the final say. New Selection values added to step kinds + input types. New per-row `collect` boolean (with master `collect_measurements` on the node) drives runtime filtering. Office instructions stay as `description` Html on both library and recipe; recipe override falls through to library when empty. Battle test exercises the full chain library → recipe → job → CoC report. **Tech Stack:** Odoo 19, Python (models, computes, migrations), OWL (recipe editor), QWeb (CoC report), JSON-RPC controllers. **Spec:** [`docs/superpowers/specs/2026-04-29-step-library-audit-design.md`](../specs/2026-04-29-step-library-audit-design.md) **Deploy target:** entech (LXC 111 on pve-worker5, native Odoo, DB `admin`, addons at `/mnt/extra-addons/custom/`). --- ## File Map | File | Status | Responsibility | |---|---|---| | `fusion_plating/models/fp_step_template.py` | MOD | Extend `default_kind`, expand `DEFAULT_INPUTS_BY_KIND`, add `action_add_common_audit_fields` | | `fusion_plating/models/fp_step_template_input.py` | MOD | Add 4 new input types to selection | | `fusion_plating/models/fp_process_node.py` | MOD | Add `collect_measurements` on node; add `collect` + `template_input_id` on input model; mirror new input types | | `fusion_plating/views/fp_step_template_views.xml` | MOD | Add audit-fields button; relabel description | | `fusion_plating/data/fp_step_template_data.xml` | NEW | Seed templates for the 8 new Step Kinds | | `fusion_plating/migrations/19.0.18.7.0/post-migrate.py` | NEW | Backfill collect/collect_measurements; re-seed defaults | | `fusion_plating/static/src/js/simple_recipe_editor.js` | MOD | Render Instructions + Measurements expansions; collect badge | | `fusion_plating/static/src/xml/simple_recipe_editor.xml` | MOD | OWL templates for new affordances | | `fusion_plating/static/src/scss/simple_recipe_editor.scss` | MOD | Styles for expansions and badges | | `fusion_plating/controllers/simple_recipe_controller.py` | MOD | New endpoints for toggle/edit/reset | | `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py` | MOD | Filter `collect=True`; mirror new input types | | `fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml` | MOD | Per-input-type widgets | | `fusion_plating_shopfloor/static/src/...` (tablet OWL) | MOD | Per-type rendering | | `fusion_plating_reports/views/report_coc_chronological.xml` | MOD | Render branches for new types; filter `collect=True` | | `fusion_plating/scripts/bt_step_library_audit.py` | NEW | 18-assertion battle-test | | `fusion_plating/__manifest__.py` | MOD | Bump to `19.0.18.7.0` | | `fusion_plating_jobs/__manifest__.py` | MOD | Bump version | | `fusion_plating_reports/__manifest__.py` | MOD | Bump version | --- ## Phase A — Model Foundations ### Task A1: Add new input types to library template input model **Files:** - Modify: `fusion_plating/models/fp_step_template_input.py` - [ ] **Step 1: Open the file and locate the `input_type` Selection at line ~27** - [ ] **Step 2: Extend the selection with 4 new types** Replace: ```python input_type = fields.Selection([ ('text', 'Text'), ('number', 'Number'), ('boolean', 'Yes/No'), ('selection', 'Selection'), ('date', 'Date / Time'), ('signature', 'Signature'), ('time_hms', 'Time (HH:MM:SS)'), ('time_seconds', 'Time (seconds)'), ('temperature', 'Temperature'), ('thickness', 'Thickness'), ('pass_fail', 'Pass / Fail'), ], string='Input Type', required=True, default='text') ``` With: ```python input_type = fields.Selection([ ('text', 'Text'), ('number', 'Number'), ('boolean', 'Yes/No'), ('selection', 'Selection'), ('date', 'Date / Time'), ('signature', 'Signature'), ('time_hms', 'Time (HH:MM:SS)'), ('time_seconds', 'Time (seconds)'), ('temperature', 'Temperature'), ('thickness', 'Thickness'), ('pass_fail', 'Pass / Fail'), ('photo', 'Photo'), ('multi_point_thickness', 'Multi-Point Thickness (avg)'), ('bath_chemistry_panel', 'Bath Chemistry Panel'), ('ph', 'pH'), ], string='Input Type', required=True, default='text') ``` - [ ] **Step 3: Verify Python parses cleanly** Run: `python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_step_template_input.py').read())"` Expected: silent (no error). ### Task A2: Mirror new input types on the recipe-step (process node) input model **Files:** - Modify: `fusion_plating/models/fp_process_node.py:621-640` (the `input_type` Selection on the inline input model) - [ ] **Step 1: Locate the input_type Selection block (search for `('thickness', 'Thickness')`)** - [ ] **Step 2: Add the four new entries above the closing bracket** Replace: ```python input_type = fields.Selection( [ ('text', 'Text'), ('number', 'Number'), ('boolean', 'Yes/No'), ('selection', 'Selection'), ('time_hms', 'Time (HH:MM:SS)'), ('time_seconds', 'Time (seconds)'), ('temperature', 'Temperature'), ('thickness', 'Thickness'), ('pass_fail', 'Pass / Fail'), ('date', 'Date / Time'), ('signature', 'Signature'), ('location_picker', 'Location Picker'), ('customer_wo', 'Customer WO #'), ], string='Input Type', required=True, default='text', ) ``` With: ```python input_type = fields.Selection( [ ('text', 'Text'), ('number', 'Number'), ('boolean', 'Yes/No'), ('selection', 'Selection'), ('time_hms', 'Time (HH:MM:SS)'), ('time_seconds', 'Time (seconds)'), ('temperature', 'Temperature'), ('thickness', 'Thickness'), ('pass_fail', 'Pass / Fail'), ('date', 'Date / Time'), ('signature', 'Signature'), ('location_picker', 'Location Picker'), ('customer_wo', 'Customer WO #'), ('photo', 'Photo'), ('multi_point_thickness', 'Multi-Point Thickness (avg)'), ('bath_chemistry_panel', 'Bath Chemistry Panel'), ('ph', 'pH'), ], string='Input Type', required=True, default='text', ) ``` ### Task A3: Add `collect`, `template_input_id`, `collect_measurements` fields **Files:** - Modify: `fusion_plating/models/fp_process_node.py` - [ ] **Step 1: Locate the inline input model class (it's a nested model inside fp_process_node.py — search for `_name = 'fusion.plating.process.node.input'`).** - [ ] **Step 2: Add `collect` and `template_input_id` after the `compliance_tag` field (around line 698)** Insert immediately before the class' closing `_sql_constraints` or the next field block: ```python # ===== Sub 12d — per-recipe configurability ============================= collect = fields.Boolean( string='Collect This Measurement', default=True, help='Toggle off to skip this prompt at runtime without deleting it. ' 'Recipe authors use this to opt out of library-seeded prompts ' 'without affecting the library itself.', ) template_input_id = fields.Many2one( 'fp.step.template.input', string='Source Library Prompt', ondelete='set null', help='Set when this row was snapshot-copied from a library template ' 'prompt. Powers "Reset to Library Defaults" — rows where this ' 'is False are treated as recipe-only custom prompts and ' 'survive the reset.', ) ``` - [ ] **Step 3: Add `collect_measurements` boolean on the parent node model** Locate the main `fusion.plating.process.node` model class (search for `_name = 'fusion.plating.process.node'` — the node itself, not the input child). Find a sensible location near the existing `description` field (search for `description = fields.Html`) and add immediately after: ```python collect_measurements = fields.Boolean( string='Collect Measurements at Runtime', default=True, help='Master switch. When off, the operator wizard skips this step ' 'entirely (no input prompts shown). Use for housekeeping steps ' 'or when no measurement is needed for this recipe.', ) ``` - [ ] **Step 4: Verify Python parses** Run: `python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_process_node.py').read())"` Expected: silent. - [ ] **Step 5: Commit Phase A** ```bash cd K:/Github/Odoo-Modules/fusion_plating git add fusion_plating/models/fp_step_template_input.py fusion_plating/models/fp_process_node.py git commit -m "model(step-library): add 4 new input types + per-recipe collect toggles" ``` --- ## Phase B — Library DEFAULT_INPUTS_BY_KIND + 8 New Step Kinds ### Task B1: Add 8 new Step Kinds to default_kind Selection **Files:** - Modify: `fusion_plating/models/fp_step_template.py:77-94` - [ ] **Step 1: Locate the `default_kind` Selection field (around line 77)** - [ ] **Step 2: Replace it with the expanded list** Replace: ```python default_kind = fields.Selection([ ('cleaning', 'Cleaning'), ('etch', 'Etch'), ('rinse', 'Rinse'), ('plate', 'Plating'), ('bake', 'Bake'), ('inspect', 'Inspection'), ('racking', 'Racking'), ('derack', 'De-Racking'), ('mask', 'Masking'), ('demask', 'De-Masking'), ('dry', 'Drying'), ('wbf_test', 'Water Break Free Test'), ('final_inspect', 'Final Inspection'), ('ship', 'Shipping'), ('gating', 'Gating'), ('contract_review', 'Contract Review (QA-005)'), ], string='Step Kind', help='Drives sane-default input seeding.') ``` With: ```python default_kind = fields.Selection([ ('receiving', 'Receiving / Incoming Inspection'), ('contract_review', 'Contract Review (QA-005)'), ('racking', 'Racking'), ('mask', 'Masking'), ('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'), ], string='Step Kind', help='Drives sane-default input seeding.') ``` The list is now ordered roughly by typical workflow phase to make it easier for shop authors to scan. ### Task B2: Expand DEFAULT_INPUTS_BY_KIND for all kinds **Files:** - Modify: `fusion_plating/models/fp_step_template.py:140-213` - [ ] **Step 1: Replace the `DEFAULT_INPUTS_BY_KIND` dict with the full expanded version** Replace the entire dict (lines 140 through the closing `}` around line 213) with: ```python DEFAULT_INPUTS_BY_KIND = { 'receiving': [ {'name': 'Qty Received', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10, 'required': True}, {'name': 'Qty Rejected', 'input_type': 'number', 'target_unit': 'each', 'sequence': 20}, {'name': 'Customer PO# Verified', 'input_type': 'boolean', 'sequence': 30}, {'name': 'Packing Slip #', 'input_type': 'text', 'sequence': 40}, {'name': 'Condition Notes', 'input_type': 'text', 'sequence': 50}, {'name': 'Damage Photo', 'input_type': 'photo', 'sequence': 60}, {'name': 'Inspector Initials', 'input_type': 'signature', 'sequence': 70, 'required': True}, ], 'cleaning': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 30}, {'name': 'Ultrasonic On', 'input_type': 'boolean', 'sequence': 40}, {'name': 'Titration Done', 'input_type': 'boolean', 'sequence': 50}, ], 'electroclean': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Amperage', 'input_type': 'number', 'sequence': 30, 'hint': 'A'}, {'name': 'Voltage', 'input_type': 'number', 'sequence': 40, 'hint': 'V'}, {'name': 'Current Density', 'input_type': 'number', 'sequence': 50, 'hint': 'ASF (A per sq ft)'}, {'name': 'Polarity', 'input_type': 'selection', 'sequence': 60, 'selection_options': 'anodic,cathodic,periodic'}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 70}, ], 'etch': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Acid Concentration', 'input_type': 'number', 'sequence': 30, 'hint': '% or g/L'}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 40}, {'name': 'HE Risk Flag', 'input_type': 'boolean', 'sequence': 50, 'hint': 'Hydrogen Embrittlement risk for high-strength steel'}, ], 'rinse': [ {'name': 'Rinse Type', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'cascade,spray,DI,city'}, {'name': 'Conductivity', 'input_type': 'number', 'sequence': 20, 'hint': 'µS/cm — required for DI rinses'}, {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 30}, ], 'strike': [ {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Amperage', 'input_type': 'number', 'sequence': 30, 'hint': 'A'}, {'name': 'Voltage', 'input_type': 'number', 'sequence': 40, 'hint': 'V'}, {'name': 'Current Density', 'input_type': 'number', 'sequence': 50, 'hint': 'ASF'}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 60}, ], 'plate': [ {'name': 'Actual Time', 'input_type': 'time_hms', 'target_unit': 'min', 'sequence': 10}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 20}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 30}, {'name': 'pH', 'input_type': 'ph', 'sequence': 40}, {'name': 'Bath Concentration', 'input_type': 'number', 'sequence': 50, 'hint': 'g/L'}, {'name': 'Current Density', 'input_type': 'number', 'sequence': 60, 'hint': 'ASF — electroplate only'}, {'name': 'Plating Thickness', 'input_type': 'multi_point_thickness', 'target_unit': 'in', 'sequence': 70}, ], 'replenishment': [ {'name': 'Bath ID', 'input_type': 'text', 'sequence': 10, 'required': True}, {'name': 'Chemistry Added', 'input_type': 'text', 'sequence': 20, 'hint': 'name + amount, e.g. "Nickel sulfamate 500mL"'}, {'name': 'pH Before', 'input_type': 'ph', 'sequence': 30}, {'name': 'pH After', 'input_type': 'ph', 'sequence': 40}, {'name': 'Concentration Before', 'input_type': 'number', 'sequence': 50}, {'name': 'Concentration After', 'input_type': 'number', 'sequence': 60}, {'name': 'Operator Initials', 'input_type': 'signature', 'sequence': 70, 'required': True}, ], 'wbf_test': [ {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10, 'required': True}, {'name': 'Retest Count', 'input_type': 'number', 'sequence': 20}, {'name': 'Photo on FAIL', 'input_type': 'photo', 'sequence': 30}, ], 'dry': [ {'name': 'Dry Method', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'hot air,oven,spin'}, {'name': 'Actual Time', 'input_type': 'time_seconds', 'target_unit': 's', 'sequence': 20}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 30}, ], 'bake': [ {'name': 'Time In', 'input_type': 'date', 'sequence': 10}, {'name': 'Time Out', 'input_type': 'date', 'sequence': 20}, {'name': 'Actual Temperature', 'input_type': 'temperature', 'target_unit': 'f', 'sequence': 30}, {'name': 'Oven ID', 'input_type': 'text', 'sequence': 40}, {'name': 'Chart Recorder File', 'input_type': 'photo', 'sequence': 50, 'hint': 'Attach AMS-2759 chart-recorder file'}, ], 'racking': [ {'name': 'Actual Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10, 'required': True}, {'name': 'Rack ID', 'input_type': 'text', 'sequence': 20}, {'name': 'Masking Applied', 'input_type': 'boolean', 'sequence': 30}, {'name': 'Photo of Racked Load', 'input_type': 'photo', 'sequence': 40}, ], 'derack': [ {'name': 'Actual Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10}, {'name': 'Mask Removal Method', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'mechanical,solvent,thermal,not applicable'}, {'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 30}, ], 'mask': [ {'name': 'Actual Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10}, {'name': 'Mask Material', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'Microshield,latex tape,vinyl plugs,wax,other'}, {'name': 'Photo of Masked Parts', 'input_type': 'photo', 'sequence': 30}, ], 'demask': [ {'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 10}, {'name': 'Surface Condition', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'clean,marks,needs rework'}, ], 'inspect': [ {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10, 'required': True}, {'name': 'Defect Type', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'pitting,burn,blister,peel,missing coverage,none'}, {'name': 'Thickness Sample', 'input_type': 'thickness', 'target_unit': 'in', 'sequence': 30}, {'name': 'Photo', 'input_type': 'photo', 'sequence': 40}, {'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 50}, ], 'hardness_test': [ {'name': 'Test Load', 'input_type': 'number', 'sequence': 10, 'hint': 'gf'}, {'name': 'Readings (HV/HK/HRC)', 'input_type': 'multi_point_thickness', 'sequence': 20, 'hint': 'Three indents minimum'}, {'name': 'Equipment ID', 'input_type': 'text', 'sequence': 30}, {'name': 'Last Calibration Date', 'input_type': 'date', 'sequence': 40}, ], 'adhesion_test': [ {'name': 'Test Method', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'bend,tape,burnish,file'}, {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20, 'required': True}, {'name': 'Photo of Coupon', 'input_type': 'photo', 'sequence': 30}, ], 'salt_spray': [ {'name': 'Test Duration', 'input_type': 'number', 'sequence': 10, 'hint': 'hours'}, {'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20, 'required': True}, {'name': 'Red Rust %', 'input_type': 'number', 'sequence': 30}, {'name': 'White Corrosion %', 'input_type': 'number', 'sequence': 40}, {'name': 'Lab Report', 'input_type': 'photo', 'sequence': 50, 'hint': 'Attach scanned lab report'}, ], 'final_inspect': [ {'name': 'Outgoing Part Count Verified', 'input_type': 'boolean', 'sequence': 10}, {'name': 'Qty Accepted', 'input_type': 'number', 'target_unit': 'each', 'sequence': 20}, {'name': 'Qty Rejected', 'input_type': 'number', 'target_unit': 'each', 'sequence': 30}, {'name': 'Defect Categorization', 'input_type': 'selection', 'sequence': 35, 'selection_options': 'pitting,burn,blister,peel,missing coverage,dimensional,none'}, {'name': 'Actual Coating Thickness', 'input_type': 'multi_point_thickness', 'target_unit': 'in', 'sequence': 40}, {'name': 'Dimensional Verification', 'input_type': 'pass_fail', 'sequence': 45}, {'name': 'Surface Finish (Ra)', 'input_type': 'number', 'sequence': 47, 'hint': 'µin'}, {'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50, 'required': True}, {'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 60}, ], 'packaging': [ {'name': 'Packaging Type', 'input_type': 'selection', 'sequence': 10, 'selection_options': 'VCI bag,bubble wrap,separator paper,custom crate,other'}, {'name': 'Qty Per Package', 'input_type': 'number', 'target_unit': 'each', 'sequence': 20}, {'name': 'Package Count', 'input_type': 'number', 'sequence': 30}, {'name': 'Cert Package Included', 'input_type': 'boolean', 'sequence': 40}, {'name': 'Customer-Supplied Packaging', 'input_type': 'boolean', 'sequence': 50}, ], 'ship': [ {'name': 'Outgoing Qty', 'input_type': 'number', 'target_unit': 'each', 'sequence': 10, 'required': True}, {'name': 'Carrier', 'input_type': 'selection', 'sequence': 20, 'selection_options': 'UPS,FedEx,Purolator,Customer Pickup,Other'}, {'name': 'Tracking #', 'input_type': 'text', 'sequence': 30}, {'name': 'BoL #', 'input_type': 'text', 'sequence': 40}, {'name': 'Photo of Sealed Shipment', 'input_type': 'photo', 'sequence': 50}, ], 'gating': [], 'contract_review': [ {'name': 'Reviewer Initials', 'input_type': 'signature', 'sequence': 10}, {'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20}, {'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30}, ], } ``` ### Task B3: Add `action_add_common_audit_fields` method **Files:** - Modify: `fusion_plating/models/fp_step_template.py` (after `action_seed_default_inputs`, around line 234) - [ ] **Step 1: Append after `action_seed_default_inputs`** ```python COMMON_AUDIT_FIELDS = [ {'name': 'Operator Initials', 'input_type': 'signature', 'required': True, 'sequence': 800}, {'name': 'Bath ID', 'input_type': 'text', 'sequence': 810}, {'name': 'Photo on Failure', 'input_type': 'photo', 'sequence': 820, 'hint': 'upload only if failure observed'}, {'name': 'Equipment ID', 'input_type': 'text', 'sequence': 830}, ] def action_add_common_audit_fields(self): """Idempotently append the common audit fields to this template. Skips rows whose name already exists. Logs to chatter. """ Input = self.env['fp.step.template.input'] for tpl in self: existing_names = set(tpl.input_template_ids.mapped('name')) added = [] for spec in self.COMMON_AUDIT_FIELDS: if spec['name'] in existing_names: continue Input.create({ 'template_id': tpl.id, **spec, }) added.append(spec['name']) if added: tpl.message_post( body=_('Added common audit fields: %s') % ', '.join(added), message_type='notification', subtype_xmlid='mail.mt_note', ) return True ``` - [ ] **Step 2: Verify Python parses** Run: `python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_step_template.py').read())"` Expected: silent. - [ ] **Step 3: Commit Phase B model changes** ```bash git add fusion_plating/models/fp_step_template.py git commit -m "model(step-library): add 8 new Step Kinds + expanded defaults + audit-fields one-click" ``` --- ## Phase C — Library Views ### Task C1: Surface "Add Common Audit Fields" button + relabel description **Files:** - Modify: `fusion_plating/views/fp_step_template_views.xml` - [ ] **Step 1: In the form view's `
` (around line 32), add a second button after `action_seed_default_inputs`** Replace: ```xml
``` With: ```xml
``` - [ ] **Step 2: Update the Instructions tab label and the field placeholder** Replace: ```xml ``` With: ```xml ``` - [ ] **Step 3: Commit** ```bash git add fusion_plating/views/fp_step_template_views.xml git commit -m "view(step-library): audit-fields button + relabel as Default Operator Instructions" ``` --- ## Phase D — Migration Script ### Task D1: Create migration directory and post-migrate script **Files:** - Create: `fusion_plating/migrations/19.0.18.7.0/post-migrate.py` - [ ] **Step 1: Verify migration directory exists** Run: `ls K:/Github/Odoo-Modules/fusion_plating/fusion_plating/migrations/` If `19.0.18.7.0/` doesn't exist, create it: `mkdir -p K:/Github/Odoo-Modules/fusion_plating/fusion_plating/migrations/19.0.18.7.0/` - [ ] **Step 2: Write the migration script** Content: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Post-migration for 19.0.18.7.0 — Step Library audit expansion. 1. Default `collect=True` on all existing recipe-step inputs. 2. Default `collect_measurements=True` on all existing recipe steps. 3. Re-run action_seed_default_inputs on every existing template to pull in the newly-added prompts (idempotent — skips rows whose name is already present, so user edits survive). 4. Backfill template_input_id by name-matching against the linked library template (best-effort). """ import logging _logger = logging.getLogger(__name__) def migrate(cr, version): if not version: return from odoo.api import Environment, SUPERUSER_ID env = Environment(cr, SUPERUSER_ID, {}) # 1. Default collect=True on all recipe-step inputs that have NULL collect cr.execute(""" UPDATE fusion_plating_process_node_input SET collect = TRUE WHERE collect IS NULL """) _logger.info("Backfilled collect=True on %s recipe-step inputs", cr.rowcount) # 2. Default collect_measurements=True on recipe steps with NULL cr.execute(""" UPDATE fusion_plating_process_node SET collect_measurements = TRUE WHERE collect_measurements IS NULL """) _logger.info("Backfilled collect_measurements=True on %s recipe steps", cr.rowcount) # 3. Re-seed defaults on every existing template (idempotent) Template = env['fp.step.template'] templates = Template.search([('default_kind', '!=', False)]) for tpl in templates: try: tpl.action_seed_default_inputs() except Exception as e: _logger.warning( "Failed to re-seed defaults on template %s: %s", tpl.id, e ) _logger.info("Re-seeded defaults on %s templates", len(templates)) # 4. Backfill template_input_id — name-match recipe-node inputs against # their parent recipe's first-level library-template snapshot. Best # effort: nodes without a clear library link stay with template_input_id=False. cr.execute(""" SELECT ni.id, ni.name, ni.node_id FROM fusion_plating_process_node_input ni WHERE ni.template_input_id IS NULL """) rows = cr.fetchall() matched = 0 for ni_id, name, node_id in rows: # Find the library template via the recipe node's process_type or by # tank match — best-effort, no guarantees. If the node has a # template_id field, use that directly. cr.execute(""" SELECT template_id FROM fusion_plating_process_node WHERE id = %s """, (node_id,)) row = cr.fetchone() if not row or not row[0]: continue tpl_id = row[0] # Find the matching input by name in the library template cr.execute(""" SELECT id FROM fp_step_template_input WHERE template_id = %s AND name = %s LIMIT 1 """, (tpl_id, name)) match = cr.fetchone() if match: cr.execute(""" UPDATE fusion_plating_process_node_input SET template_input_id = %s WHERE id = %s """, (match[0], ni_id)) matched += 1 _logger.info("Backfilled template_input_id on %s recipe-step inputs", matched) ``` NOTE: the SQL queries use the actual Odoo table names. The recipe-step model is `fusion.plating.process.node` → table `fusion_plating_process_node`. The inline input model is `fusion.plating.process.node.input` → table `fusion_plating_process_node_input`. **Verify these table names** before running the migration by running: ```bash ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -s /bin/bash -c \"psql -d admin -c \\\"\\\\dt | grep -i process_node\\\"\"'" ``` If the actual table names differ, update the SQL accordingly. - [ ] **Step 3: Verify migration directory has __init__ if needed** Odoo migrations don't strictly need an `__init__.py` in the version directory — they're picked up by file naming. Skip unless your repo convention says otherwise. - [ ] **Step 4: Commit** ```bash git add fusion_plating/migrations/19.0.18.7.0/post-migrate.py git commit -m "migration(step-library): backfill collect/collect_measurements + re-seed defaults" ``` --- ## Phase E — Runtime Wizard Filtering ### Task E1: Filter input wizard to `collect=True` only + skip when master is off **Files:** - Modify: `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py:64-92` - [ ] **Step 1: Locate the `default_get` method (around line 64)** - [ ] **Step 2: Update the filter logic** Replace: ```python @api.model def default_get(self, fields_list): defaults = super().default_get(fields_list) ctx = self.env.context step_id = ctx.get('default_step_id') or ctx.get('active_id') if not step_id: return defaults step = self.env['fp.job.step'].browse(step_id) if not step.exists() or not step.recipe_node_id: return defaults defaults['step_id'] = step.id node = step.recipe_node_id # Filter to step_input prompts only — transition inputs go on the # Move wizard, not here. inputs = node.input_ids if 'kind' in inputs._fields: inputs = inputs.filtered(lambda i: i.kind == 'step_input') defaults['line_ids'] = [(0, 0, { 'node_input_id': inp.id, 'name': inp.name, 'input_type': inp.input_type, 'target_min': getattr(inp, 'target_min', 0.0) or 0.0, 'target_max': getattr(inp, 'target_max', 0.0) or 0.0, 'target_unit': getattr(inp, 'target_unit', False) or False, }) for inp in inputs] return defaults ``` With: ```python @api.model def default_get(self, fields_list): defaults = super().default_get(fields_list) ctx = self.env.context step_id = ctx.get('default_step_id') or ctx.get('active_id') if not step_id: return defaults step = self.env['fp.job.step'].browse(step_id) if not step.exists() or not step.recipe_node_id: return defaults defaults['step_id'] = step.id node = step.recipe_node_id # Master switch — when off, skip the wizard entirely. if hasattr(node, 'collect_measurements') and not node.collect_measurements: return defaults # Filter to step_input prompts only — transition inputs go on the # Move wizard, not here. Also filter to collect=True (per-recipe # opt-out, default True). inputs = node.input_ids if 'kind' in inputs._fields: inputs = inputs.filtered(lambda i: i.kind == 'step_input') if 'collect' in inputs._fields: inputs = inputs.filtered(lambda i: i.collect) defaults['line_ids'] = [(0, 0, { 'node_input_id': inp.id, 'name': inp.name, 'input_type': inp.input_type, 'target_min': getattr(inp, 'target_min', 0.0) or 0.0, 'target_max': getattr(inp, 'target_max', 0.0) or 0.0, 'target_unit': getattr(inp, 'target_unit', False) or False, }) for inp in inputs] return defaults ``` ### Task E2: Mirror new input types on wizard line model **Files:** - Modify: `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py` (top of file, around line 30) - [ ] **Step 1: Update `_FP_INPUT_TYPE_SELECTION` to include the 4 new types** Replace: ```python _FP_INPUT_TYPE_SELECTION = [ ('text', 'Text'), ('number', 'Number'), ('boolean', 'Yes/No'), ('selection', 'Selection'), ('date', 'Date / Time'), ('signature', 'Signature'), ('time_hms', 'Time (HH:MM:SS)'), ('time_seconds', 'Time (seconds)'), ('temperature', 'Temperature'), ('thickness', 'Thickness'), ('pass_fail', 'Pass / Fail'), ] ``` With: ```python _FP_INPUT_TYPE_SELECTION = [ ('text', 'Text'), ('number', 'Number'), ('boolean', 'Yes/No'), ('selection', 'Selection'), ('date', 'Date / Time'), ('signature', 'Signature'), ('time_hms', 'Time (HH:MM:SS)'), ('time_seconds', 'Time (seconds)'), ('temperature', 'Temperature'), ('thickness', 'Thickness'), ('pass_fail', 'Pass / Fail'), ('photo', 'Photo'), ('multi_point_thickness', 'Multi-Point Thickness (avg)'), ('bath_chemistry_panel', 'Bath Chemistry Panel'), ('ph', 'pH'), ] ``` - [ ] **Step 2: Find the wizard line model class (likely later in the same file or in `fp_job_step_input_wizard_line.py`)** If the line model uses `_FP_INPUT_TYPE_SELECTION` directly, no further code change needed. Confirm by searching: ```bash grep -n "_FP_INPUT_TYPE_SELECTION\|class FpJobStepInputWizardLine" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py ``` - [ ] **Step 3: Add Binary field for photo storage and Char fields for composite types on the line model** Find the line model class (`FpJobStepInputWizardLine` or similar) and append after the existing value fields: ```python # Sub 12d — composite + photo input types. # Photo: stored as ir.attachment via Binary; we capture the bytes # then on commit write to attachment + null this field. photo_value = fields.Binary(string='Photo', attachment=True) photo_filename = fields.Char(string='Photo Filename') # Multi-point thickness: 5 readings + computed average. point_1 = fields.Float(string='R1') point_2 = fields.Float(string='R2') point_3 = fields.Float(string='R3') point_4 = fields.Float(string='R4') point_5 = fields.Float(string='R5') point_avg = fields.Float( string='Average', compute='_compute_point_avg', store=False, ) # Bath chemistry panel: 4 fields bundled. panel_ph = fields.Float(string='Panel pH') panel_concentration = fields.Float(string='Panel Concentration') panel_temperature = fields.Float(string='Panel Temperature') panel_bath_id = fields.Char(string='Panel Bath ID') @api.depends('point_1', 'point_2', 'point_3', 'point_4', 'point_5') def _compute_point_avg(self): for rec in self: pts = [ p for p in (rec.point_1, rec.point_2, rec.point_3, rec.point_4, rec.point_5) if p ] rec.point_avg = sum(pts) / len(pts) if pts else 0.0 ``` - [ ] **Step 4: Update `action_commit` to serialize composite values into the move's input_data JSON** Find the `action_commit` method on the wizard. Update it to handle the new types — when serializing each line's value, branch on `input_type`: ```python def _serialize_line_value(self, line): """Convert a wizard line into a JSON-serializable value for storage on fp.job.step.move.input_data.""" t = line.input_type if t == 'photo': # Photo is stored as ir.attachment; the move row carries the ID. if line.photo_value: att = self.env['ir.attachment'].create({ 'name': line.photo_filename or 'photo.jpg', 'datas': line.photo_value, 'res_model': 'fp.job.step.move', 'res_id': 0, # patched after move is created }) return {'attachment_id': att.id, 'filename': att.name} return None if t == 'multi_point_thickness': pts = [line.point_1, line.point_2, line.point_3, line.point_4, line.point_5] non_empty = [p for p in pts if p] return { 'readings': pts, 'avg': sum(non_empty) / len(non_empty) if non_empty else 0.0, } if t == 'bath_chemistry_panel': return { 'ph': line.panel_ph, 'concentration': line.panel_concentration, 'temperature': line.panel_temperature, 'bath_id': line.panel_bath_id, } if t == 'ph': return line.value_number # Fallback for existing types — use the existing value getter return getattr(line, 'value_text', None) or getattr(line, 'value_number', 0.0) ``` Then call `self._serialize_line_value(line)` from within `action_commit` instead of inline value extraction. The exact integration point depends on the existing commit logic — read it first, then thread the new helper in. - [ ] **Step 5: Verify Python parses** ```bash python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py').read())" ``` - [ ] **Step 6: Commit** ```bash git add fusion_plating_jobs/wizards/fp_job_step_input_wizard.py git commit -m "wizard(step-input): filter to collect=True + new input types serialization" ``` ### Task E3: Wizard form view — conditional widgets per input type **Files:** - Modify: `fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml` - [ ] **Step 1: Read the existing form to find the line list/form** ```bash cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml ``` - [ ] **Step 2: Add conditional render for each new type in the per-row form** Inside the `
` block of `line_ids`, add per-type conditional fields. Each branch is wrapped in `invisible="input_type != ''"`: ```xml ``` (The `value_number` reuse for pH is intentional — pH is just a constrained number.) - [ ] **Step 3: Commit** ```bash git add fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml git commit -m "view(step-input-wizard): conditional widgets per input type" ``` --- ## Phase F — Recipe Editor UI (Simple Editor) ### Task F1: Add backend controllers for the new editor actions **Files:** - Modify: `fusion_plating/controllers/simple_recipe_controller.py` - [ ] **Step 1: Read the existing controller to understand patterns** ```bash grep -n "def\|@http.route" K:/Github/Odoo-Modules/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py | head -30 ``` - [ ] **Step 2: Append the four new endpoints** ```python @http.route('/fp/simple_recipe/step/toggle_collect', type='jsonrpc', auth='user') def 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.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 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, required, sequence, selection_options. """ Input = request.env['fusion.plating.process.node.input'] rec = Input.browse(int(input_id)) if not rec.exists(): return {'ok': False, 'error': 'not_found'} 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/edit_instructions', type='jsonrpc', auth='user') def edit_instructions(self, node_id, description): """Set the recipe step's per-recipe instructions override. Pass empty string / None to revert to library default.""" node = request.env['fusion.plating.process.node'].browse(int(node_id)) node.description = description or False return {'ok': True} @http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user') def 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.""" 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.template_id: return {'ok': False, 'error': 'no_library_template'} tpl = node.template_id # Drop existing rows that came from the library (template_input_id set) node.input_ids.filtered(lambda i: 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, }) # Reset description override node.description = tpl.description or False node.message_post( body='Reset to library defaults from template "%s"' % tpl.name, message_type='notification', ) return {'ok': True} ``` NOTE: the field/relation names (`template_id`, `node_id`, `input_template_ids`, `kind`) need to match what's actually in the model. **Verify by reading `fp_process_node.py` carefully before pasting** — adjust if names differ. - [ ] **Step 3: Commit** ```bash git add fusion_plating/controllers/simple_recipe_controller.py git commit -m "controller(simple-recipe): toggle/edit/reset endpoints for per-recipe configurability" ``` ### Task F2: OWL editor — render Instructions + Measurements expansions **Files:** - Modify: `fusion_plating/static/src/js/simple_recipe_editor.js` - Modify: `fusion_plating/static/src/xml/simple_recipe_editor.xml` - Modify: `fusion_plating/static/src/scss/simple_recipe_editor.scss` - [ ] **Step 1: Read the existing OWL component to understand state shape and step rendering** ```bash grep -n "expanded\|step\|library" K:/Github/Odoo-Modules/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js | head -30 ``` - [ ] **Step 2: Add state for which step's expansions are open** In the `state = useState({ ... })` block, add: ```js expandedInstructions: null, // node_id whose instructions panel is open expandedMeasurements: null, // node_id whose measurements panel is open ``` - [ ] **Step 3: Add toggle methods** In the component class, add: ```js toggleInstructions(nodeId) { this.state.expandedInstructions = this.state.expandedInstructions === nodeId ? null : nodeId; this.state.expandedMeasurements = null; } toggleMeasurements(nodeId) { this.state.expandedMeasurements = this.state.expandedMeasurements === nodeId ? null : nodeId; this.state.expandedInstructions = null; } async toggleCollectMeasurements(nodeId, collect) { await rpc("/fp/simple_recipe/step/toggle_collect", { node_id: nodeId, collect, }); // Refresh the step from server await this.loadAll(); } async toggleInputCollect(inputId, collect) { await rpc("/fp/simple_recipe/step/edit_input", { input_id: inputId, payload: { collect }, }); await this.loadAll(); } async saveInstructions(nodeId, html) { await rpc("/fp/simple_recipe/step/edit_instructions", { node_id: nodeId, description: html, }); await this.loadAll(); } async resetToLibrary(nodeId) { await rpc("/fp/simple_recipe/step/reset_to_library", { node_id: nodeId }); await this.loadAll(); } ``` - [ ] **Step 4: Update the OWL template to render the expansion affordances** In `simple_recipe_editor.xml`, find the per-step rendering (look for the `t-foreach` over selected steps). After the step header div, add: ```xml